"Works on my machine" is the problem Docker was built to solve. Packaging a PHP application together with its exact PHP version, extensions, and system dependencies into a container means the same artifact runs identically on a developer's laptop, a CI pipeline, and production — eliminating an entire category of environment-mismatch bugs that have nothing to do with the application code itself.
Writing a Minimal PHP Dockerfile
A PHP Dockerfile typically starts from an official PHP base image, installs required extensions, copies application code, and installs dependencies via Composer inside the build itself rather than relying on a vendor directory copied in from outside the container.
FROM php:8.2-fpm
RUN apt-get update && apt-get install -y libpq-dev \
&& docker-php-ext-install pdo pdo_mysql
WORKDIR /var/www
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader
COPY . .
CMD ["php-fpm"]Multi-Stage Builds Keep Images Lean
Installing build tools (Composer itself, dev dependencies, compilers for certain extensions) directly into your final production image bloats it unnecessarily, since none of that is needed at runtime. A multi-stage build installs and builds in one stage, then copies only the final, necessary artifacts into a clean final stage, producing a smaller image that starts faster and has less surface area for vulnerabilities.
Configuration Still Belongs in Environment Variables
Containerizing an application does not change the rule from the previous guide: secrets and environment-specific configuration still belong in environment variables passed into the container at runtime, never baked into the image itself. An image containing a hardcoded production database password is a security incident waiting to happen the moment that image is pushed to any registry, even a private one.
docker run -e DB_PASSWORD=secret -e APP_ENV=production myapp:latestHealth Checks and Graceful Shutdown
An orchestrator (Docker Compose, Kubernetes) needs a way to know whether a running container is actually healthy, not just that the process has not crashed outright. Defining an explicit health check endpoint the orchestrator can poll, and handling shutdown signals gracefully so in-flight requests finish before a container stops, are both necessary for reliable, zero-downtime deployments rather than ones that occasionally drop requests during a rollout.
Docker Compose for Local Development
Running a PHP application locally alongside a database, cache, and queue worker means juggling multiple separately-installed services without containers. Docker Compose defines all of these as services in one file, started together with a single command, giving every developer on a team an identical local environment regardless of what they happen to have installed natively on their own machine.
services:
app:
build: .
volumes: [".:/var/www"]
db:
image: mysql:8
environment:
MYSQL_DATABASE: myappPersisting Data With Volumes
A container's filesystem is ephemeral by default — removing a database container without a volume destroys all its data along with it. Mounting a named volume for any service that needs to persist data across container restarts (the database, uploaded files) is essential; relying on the container's own writable layer for anything you actually need to keep is a mistake that surfaces painfully the first time a container gets recreated.
Running Migrations and Artisan Commands in Containers
A containerized PHP application still needs to run one-off commands — database migrations during deploy, a queue worker as a long-running process. Running these via docker exec against a running container, or as a separate, dedicated container sharing the same image but a different command, keeps these operational tasks consistent with the same environment the main application runs in, rather than running them from a developer's differently-configured local machine against production data.
docker exec -it myapp-container php artisan migrateImage Tagging and Rollback Strategy
Deploying with a floating tag like "latest" makes it unclear exactly which code version is actually running at any given moment, and makes rolling back to a known-good previous version awkward. Tagging images with a specific version or commit hash, and keeping several recent tagged images available in your registry, lets a bad deploy be rolled back quickly by simply redeploying the previous known-good tag rather than scrambling to rebuild it from source under pressure.
Case Study: The Image That Would Not Stop Growing
A team's production Docker image grew to over two gigabytes after a year of incremental Dockerfile changes, each individually reasonable but collectively bloating the image with build tools, cached package manager files, and unused dependencies never cleaned up. Deploys slowed noticeably as the image took longer to push, pull, and start. A deliberate audit, converting to a multi-stage build and explicitly cleaning package manager caches within each layer, cut the image down to under 200 megabytes, with deploy times dropping proportionally and no change to the application's actual runtime behavior.
A Glossary for This Topic
Image: an immutable snapshot containing an application and its dependencies, used to create containers. Container: a running instance of an image. Multi-stage build: a Dockerfile pattern using separate build and runtime stages to keep the final image lean. Registry: a storage and distribution service for container images. Volume: persistent storage attached to a container, surviving container restarts and removal.
Frequently Asked Questions
Do I need Docker for a small project? Not necessarily, but the consistency benefit across environments often pays for the setup cost even on smaller projects, especially once more than one developer is involved.
Should the database run in the same container as the application? No, generally each service (app, database, cache) should run in its own container, composed together, for independent scaling and lifecycle management.
How do I handle file uploads with containers? Use a persistent volume or, for production, an external object storage service, since a container's own filesystem should not be relied on as durable storage.
Step-by-Step: Dockerizing an Existing PHP Application
First, write a minimal Dockerfile installing only the PHP extensions your application actually requires. Second, get the application building and running locally in a container before worrying about optimization. Third, convert to a multi-stage build, separating dependency installation from the final runtime image. Fourth, externalize all configuration to environment variables passed at runtime, removing any hardcoded local-environment assumptions from the codebase. Fifth, add a health check endpoint and test that the container restarts cleanly and gracefully handles shutdown signals.
A Comparison Table: Deployment Approaches
| Approach | Consistency | Operational Complexity |
|---|---|---|
| Manual server deploy (FTP/rsync) | Low, environment drift over time | Low |
| Docker container | High, identical across environments | Medium |
| Managed platform (PaaS) | High | Low, but less control |
Security Considerations Checklist
Never run application containers as the root user, since a container compromise combined with root privileges inside it meaningfully increases the potential damage compared to a properly restricted, non-root user. Scan images for known vulnerabilities in base images and dependencies as a routine part of your build pipeline, not a one-time check. Keep secrets out of image layers entirely, including build-time arguments that can persist in image history even if removed from the final running container's visible environment.
Accessibility Considerations
Containerization has no direct accessibility dimension, but consistent environments do indirectly support it — a Docker-based local development setup that exactly matches production reduces the "it works differently locally" friction that can otherwise cause accessibility issues to go unnoticed locally and only surface after deployment.
How This Plays Out at Different Scales
A small project can run a single container with minimal orchestration. A growing application typically needs Docker Compose or similar for managing multiple coordinated services locally and in staging. A large-scale system usually needs a full orchestration platform (Kubernetes or similar) for automated scaling, health-check-driven recovery, and rolling deployments across many container instances.
What to Do When You Inherit an Undocumented, Manually-Configured Production Server
Inheriting a production server configured manually over years, with no Dockerfile, no infrastructure-as-code, and tribal knowledge as the only documentation, is a common and risky starting point. Resist the urge to containerize everything in one large effort; instead, document the current server's actual configuration first by inspecting it directly, then build a Dockerfile that reproduces that exact configuration, verified side-by-side against the real server before ever considering a cutover, rather than guessing at what the "correct" configuration should be from scratch.
Final Checklist Before Trusting a Containerized Deployment
The image builds reproducibly from a clean checkout with no manual steps required. No secrets are baked into any image layer, verified by inspecting image history directly. Health checks exist and have been tested against both healthy and genuinely unhealthy states. Graceful shutdown has been tested under realistic in-flight-request conditions. A rollback plan exists using a specific, previously-verified image tag.
Closing Thought, Revisited
Containers do not make a deployment process good on their own; they make a good deployment process portable and repeatable. The real value comes from the discipline forced by writing everything down in a Dockerfile — every dependency, every configuration assumption — rather than letting a production server's actual configuration remain a mystery only one person on the team fully understands.
Logging From Inside a Container
A container writing logs to its own internal filesystem loses those logs entirely the moment the container is removed or recreated, which happens routinely during normal deploys and scaling events. Writing application logs to stdout/stderr, and letting the container runtime or orchestrator handle collection and shipping to a persistent destination, avoids this loss and matches the standard container logging convention most tooling expects by default.
Resource Limits Prevent One Container From Starving Others
A container with no defined memory or CPU limit can, under an unexpected load spike or a memory leak, consume resources needed by other containers sharing the same host, degrading or crashing unrelated services. Setting explicit resource limits per container, sized appropriately for what that specific service genuinely needs, contains the blast radius of one service's problem to itself rather than letting it cascade across everything sharing the host.
Layer Caching and Build Speed
Docker caches each instruction's resulting layer, reusing it on subsequent builds if nothing relevant changed, which makes ordering Dockerfile instructions thoughtfully a real, measurable build-speed lever. Placing rarely-changing instructions (installing system dependencies) before frequently-changing ones (copying application code) means a typical code-only change only rebuilds the small final layers, not the entire image from scratch every time.
Networking Between Containers
Containers on the same Docker network can reach each other by service name rather than IP address, which is what lets an application container connect to a database container using a simple hostname like "db" regardless of whatever IP address Docker happens to assign it on a given run. Relying on this name-based resolution, rather than hardcoding IP addresses anywhere in application configuration, keeps the setup portable across different hosts and restarts.
Choosing a Base Image Deliberately
The php:8.2-fpm base image is a reasonable general default, but Alpine-based variants produce significantly smaller images at the cost of occasional compatibility quirks with certain PHP extensions that expect glibc rather than musl. Evaluating this tradeoff deliberately for your specific application's actual extension requirements, rather than defaulting blindly to whichever base image a tutorial happened to use, avoids both unnecessary image bloat and unexpected extension-compatibility surprises discovered only after a build already succeeded.
Environment Parity Between Local Docker and Production
A local Docker Compose setup using a different PHP version or missing extensions present in production defeats much of the consistency benefit Docker is meant to provide. Pinning the exact same base image tag locally and in production, rather than a generic "latest" that can drift between when a developer last rebuilt locally and when production was last deployed, keeps the core consistency promise of containerization actually true in practice.
Build Arguments Versus Runtime Environment Variables
Docker build arguments are available only during the image build process and do not persist into the running container unless explicitly passed through, while runtime environment variables are set when a container starts and can change between runs of the same image. Using build arguments for things that genuinely affect how the image itself is built (a version number baked into the build), and runtime environment variables for everything that should be configurable without rebuilding the image, keeps this distinction clean and avoids confusing, hard-to-debug configuration behavior.