Docker Compose for Local PHP Development
Define a multi-service local development environment for a PHP application using Docker Compose — including PHP-FPM, Nginx, a database, and Redis — with networking, bind-mount and named volumes, and health-check-gated startup ordering, while understanding why Compose is a local/testing tool rather than a production orchestrator.
A realistic PHP application is never just PHP — it needs a web server in front of it, a database behind it, and often a cache like Redis alongside it, and getting all of those running together consistently on every developer's machine used to mean a long, error-prone README of manual installation steps. Docker Compose solves this by letting a single YAML file declare every service an application needs, how they network together, what data persists between restarts, and in what order they should become ready — turning environment setup into a single docker compose up command. This lesson builds a complete local development stack for a PHP application: PHP-FPM, Nginx, MySQL, and Redis, and explains the concepts (services, networks, volumes, health checks) that make it work, along with where Compose's usefulness ends.
Services: Declaring Each Piece of the Stack
A docker-compose.yml file's central concept is the service: a named, logical component of the application, each backed by a container built either from a Dockerfile (build: .) or a pre-built image (image: mysql:8.0). A typical PHP web application defines at minimum a php (or app) service running PHP-FPM built from the project's own Dockerfile, a nginx service using the official Nginx image with a mounted configuration file, and a db service using an official MySQL or PostgreSQL image — and very often a redis service for caching, sessions, or queue backing, using the lightweight official redis:alpine image.
Each service can be given its own environment variables (environment: or an .env-referencing env_file:), letting the same application image be reused for the web service, a queue-worker service, and a scheduled-task service just by changing the command: and a couple of environment variables — this mirrors how the exact same image should be deployed in different roles in production, and is one of Compose's genuine strengths for keeping local dev honest about what the 'app image' actually contains.
Compose also supports depends_on to express one service's startup dependency on another (nginx depends on php; php depends on db), which by default only waits for the dependency's container to start (not for the application inside it to actually be ready) — a distinction that becomes important in the health-check section below, since 'container started' and 'MySQL is ready to accept connections' are not the same moment at all.
• A service = a named container, built from a Dockerfile or a pre-built image. • Typical PHP stack: php (FPM), nginx, db (mysql/postgres), redis. • The same application image can serve multiple roles (web, worker, scheduler) via different command: overrides. • depends_on controls startup order but, by default, only waits for a container to start, not for its app to be ready.
Networking: Reaching Services by Name
Docker Compose automatically creates a private, isolated bridge network for every project (named after the project's directory by default) and attaches every service defined in the file to it. Within that network, each service is reachable by other services using its service name as a hostname — DNS resolution is handled automatically by Docker's embedded DNS server, so the nginx service's configuration can fastcgi_pass php:9000; and the php service's database connection string can use host=db — no IP addresses, no manual /etc/hosts entries, and no hardcoded localhost (which, crucially, would mean 'this container itself' inside a container, not 'the other container' — a classic beginner mistake when moving from a non-containerized local setup to Compose).
Only ports explicitly published with a ports: mapping ("8080:80", meaning host port 8080 maps to container port 80) are reachable from the host machine's browser or CLI tools; every other inter-service communication happens entirely inside the Compose network and never needs to be published at all — the database, for instance, typically has no ports: entry in a properly configured stack, since only the php service (inside the same network) needs to reach it, not the developer's host machine directly (though many developers do publish the database port temporarily for connecting a local GUI client).
This service-name-based DNS is exactly what makes Compose files portable across machines: nothing in the configuration hardcodes an IP address or a machine-specific hostname, so the same docker-compose.yml produces a working, fully networked stack on any teammate's laptop.
• Compose creates one private bridge network per project; every service joins it automatically. • Services reach each other by service name as a hostname (db, redis, php) via Docker's built-in DNS. • Only ports:-mapped ports are reachable from the host machine; everything else stays internal to the network. • Never use localhost from inside one container to reach another — that means 'this container', not 'that service'.
Volumes: Bind Mounts for Code, Named Volumes for Data
Compose distinguishes two fundamentally different volume uses, and conflating them is a common source of confusion. A bind mount maps a specific directory on the host machine directly into a path inside the container (./src:/var/www/html), so edits made in the developer's editor on the host are instantly visible inside the running container without any rebuild or restart — this is exactly what makes live-reloading local development possible: change a PHP file, refresh the browser, see the change immediately, because both the host and the container are looking at the literal same files on disk.
A named volume, by contrast, is Docker-managed storage that isn't tied to any specific host directory (db_data:/var/lib/mysql), and its entire purpose is data persistence across container restarts and recreations. A database container is disposable and gets destroyed and recreated often during development (rebuilding the image, changing docker-compose.yml, running docker compose down), but the actual rows in the database should NOT disappear every time that happens — a named volume backing MySQL's data directory means the database's data lives independently of the container's lifecycle, surviving a docker compose down (though not a docker compose down -v, which explicitly deletes named volumes too).
Using a bind mount for source code and a named volume for database storage is the standard, correct pairing: code needs live host-editability and no meaningful persistence value of its own (it's already versioned in Git), while data needs the opposite — persistence and no direct host-editing need at all.
• Bind mount (./src:/var/www/html): host directory mapped in directly — live-edits appear instantly in the container. • Named volume (db_data:/var/lib/mysql): Docker-managed storage, decoupled from any container's lifecycle. • Use bind mounts for application source code; use named volumes for database/persistent data. • docker compose down keeps named volumes by default; docker compose down -v deletes them too.
Health Checks and Startup Ordering
The default depends_on: [db] only guarantees the db container has started — not that MySQL inside it has finished initializing and is ready to accept connections, which for MySQL in particular can take several real seconds after the container process itself begins. Without addressing this, the php service can start and immediately try to connect to a database that technically 'exists' as a container but isn't accepting connections yet, causing confusing, intermittent 'connection refused' errors specifically on a fresh docker compose up (and often not on subsequent restarts, since by then MySQL is already warm) — a frustrating class of bug to debug if you don't know to look for it.
The fix is a healthcheck: block defined on the dependency service, specifying a command Compose runs periodically inside that container to determine actual readiness — for MySQL, something like mysqladmin ping or a lightweight SELECT 1 query; for Redis, redis-cli ping; for a custom PHP-FPM health check, curling the /healthz endpoint from an earlier lesson. Combined with the extended dependency syntax depends_on: db: condition: service_healthy, a dependent service's container will not even start until the health check reports healthy — turning 'probably ready by now' into an explicit, verified condition.
This same health-check discipline that matters for correctness in local development is, not coincidentally, the same concept covered in the Continuous Deployment lesson for production readiness gates — Compose's service_healthy condition is a small, local-scale version of exactly the same idea: don't proceed until the dependency proves it's actually ready, not just started.
• Plain depends_on: [db] only waits for the container to start, not for the app inside it to be ready. • healthcheck: defines a periodic readiness command (mysqladmin ping, redis-cli ping, curl /healthz). • depends_on: db: condition: service_healthy blocks the dependent service until the health check passes. • This mirrors the production CD health-gate concept, just applied at local Compose startup.
Why Compose Is for Local Dev, Not Production Orchestration
Docker Compose is deliberately single-host: every service in a docker-compose.yml runs on the one machine executing docker compose up, with no built-in concept of scheduling containers across a fleet of servers, no automatic failover if that one machine dies, and no rolling-update mechanism for zero-downtime deploys of the kind covered in the Continuous Deployment lesson. That's exactly the right feature set for a developer's laptop or a CI test run — spin up everything needed, run the tests, tear it down — and exactly the wrong feature set for production traffic, which needs to survive individual machine failures and scale horizontally across many machines.
Kubernetes and AWS ECS (Elastic Container Service) exist to solve that different, harder problem: scheduling containers across a cluster of many machines, restarting failed containers automatically wherever capacity exists, load-balancing traffic across replicas, and performing the rolling/blue-green deployment patterns discussed in the CD lesson at a fleet-wide scale — none of which is Compose's job or within its design scope. Some teams use docker-compose.yml as a rough, informal sketch of what a Kubernetes manifest or ECS task definition will eventually need to express (which services exist, how they connect, what env vars they need), but that's a documentation/planning convenience, not evidence that Compose itself could run the production workload.
The practical rule of thumb: if the question is 'how do I get this whole stack running consistently on a laptop or in a CI job', reach for Compose. If the question is 'how do I run this reliably, at scale, surviving individual machine failures, for real production traffic', that's Kubernetes/ECS territory, and the Compose file's job is done once it's helped you develop and test the application that eventually gets deployed there.
• Compose runs everything on a single host — no cross-machine scheduling, failover, or horizontal scaling. • Kubernetes/ECS solve the different problem of running containers reliably across a cluster, at scale. • Compose is the right tool for local development and CI test runs, not production traffic. • A Compose file can inform a later Kubernetes/ECS design, but is not itself a production orchestration tool.
Minimal Two-Service Compose File
php only starts once db's healthcheck reports healthy, not merely once the db container has started. db_data is declared under the top-level volumes: key, making it a named, Docker-managed volume.
services:
php:
build: .
volumes:
- ./src:/var/www/html
depends_on:
db:
condition: service_healthy
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: secret
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 3s
retries: 5
volumes:
db_data:Code Examples
This is a realistic four-service local stack: nginx is the only service publishing a port to the host (8080), so the developer opens http://localhost:8080 in a browser; every other service is reached purely by name over the internal Compose network. Source code is bind-mounted into php for instant live-editing, while db_data is a named volume so a developer's test data survives container rebuilds. The health-check chain (redis and db healthy → php healthy → nginx starts) means the stack never presents a half-ready state to the developer.
depends_on: db: condition: service_healthy fastcgi_pass php:9000 uses the Compose service name php as a hostname, resolved automatically by Docker's internal DNS to whatever container IP that service currently has — this line never needs to change even if the php container is rebuilt and gets a new internal IP address.
docker compose exec runs a command inside an already-running service's container, which is how day-to-day tasks (installing a new Composer package, running a migration, opening a shell) are done without leaving the containerized environment — keeping the host machine free of any PHP installation at all.
Common mistake: Running docker compose down -v out of habit wipes named volumes, including the local database — a common way to lose local test data unintentionally.
services:
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./src:/var/www/html:ro
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
php:
condition: service_healthy
php:
build:
context: .
dockerfile: docker/Dockerfile
volumes:
- ./src:/var/www/html
environment:
DB_HOST: db
DB_NAME: app
DB_USER: app
DB_PASSWORD: secret
REDIS_HOST: redis
healthcheck:
test: ["CMD", "php-fpm-healthcheck"]
interval: 10s
timeout: 3s
retries: 3
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: app
MYSQL_USER: app
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: rootsecret
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-prootsecret"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
…server {
listen 80;
root /var/www/html/public;
index index.php;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ \.php$ {
fastcgi_pass php:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}# Start the full stack in the background
docker compose up -d
# Watch logs from just the php service
docker compose logs -f php
# Run a one-off Composer or Artisan command inside the php container
docker compose exec php composer install
docker compose exec php php artisan migrate
# Tear down containers (keeps named volumes, e.g. db_data)
docker compose down
# Tear down AND delete named volumes (wipes the local database)
docker compose down -vCompose Network and Volume Layout
Only nginx's port is published to the host; every other connection (php→db, php→redis) happens by service name inside the private Compose network.
Host machine Compose project network
┌───────────────┐ :8080 ┌───────────────────────────────┐
│ browser │ ─────────────▶ │ nginx ──fastcgi:9000──▶ php │
└───────────────┘ │ │ │
│ ▼ │
./src (bind mount) ─────────────▶│ php ──▶ db │
│ │ │ │
│ ▼ ▼ │
│ redis db_data │
│ (named │
│ volume)│
└───────────────────────────────┘Important Rules
Bind-mount application source for live-editing; use named volumes only for data that must persist (databases).
Reach other services by service name over the Compose network — never hardcode localhost or an IP address.
Only publish ports for services the host machine genuinely needs to reach directly (usually just the web server).
Use healthcheck + depends_on: condition: service_healthy for any dependency the app can't function without.
Treat Compose as a local/CI tool only — production traffic belongs on an orchestrator like Kubernetes or ECS.
Onboarding a New Developer
Teams that commit a well-built docker-compose.yml alongside their PHP codebase can onboard a new developer with literally three commands — clone the repo, docker compose up -d, docker compose exec php composer install — replacing what used to be a multi-page README of manually installing a matching PHP version, MySQL, Redis, and Nginx configuration by hand.
DB_HOST=localhost # inside the php service's environment DB_HOST=db # the Compose service name for the database
Inside a container, localhost refers to that container itself, not another service — the php container has no MySQL running on its own localhost, so this fails to connect.
php: depends_on: - db # plain list form, no health condition php: depends_on: db: condition: service_healthy
Plain depends_on only waits for the db container to start, not for MySQL inside it to be ready to accept connections — the php service can still race ahead and fail to connect on a fresh startup.
volumes: - db_data:/var/www/html # named volume used for live-edited source code volumes: -./src:/var/www/html # bind mount for source code - db_data:/var/lib/mysql # named volume for the database only
A named volume for application source code means edits made on the host are never reflected inside the container automatically — defeating the entire point of live local development.
Inside a Compose network, how does the php service reach the db service? A. By the db container's dynamically assigned IP address, looked up manually B. By using db (the service name) as a hostname, resolved automatically by Docker's internal DNS C. It must use localhost with the database's mapped host port D. Services in different Compose entries cannot communicate at all
B. By using db (the service name) as a hostname, resolved automatically by Docker's internal DNS — Docker Compose attaches every service to a shared private network and provides automatic DNS resolution by service name, so db resolves correctly regardless of the container's actual IP.
Why use a bind mount for application source code but a named volume for database data? A. There is no functional difference between the two B. Bind mounts give instant host-editability for code; named volumes persist data independent of container lifecycle, which code doesn't need and data does C. Named volumes are always faster than bind mounts D. Bind mounts cannot be used with PHP-FPM
B. Bind mounts give instant host-editability for code; named volumes persist data independent of container lifecycle, which code doesn't need and data does — Code benefits from live host-editing without persistence concerns (it's already in Git); database data needs to survive container recreation and has no need for direct host-file editing.
Why might a plain depends_on: [db] (without a health condition) cause intermittent connection failures on a fresh docker compose up? A. depends_on has no effect on startup order at all B. It only waits for the db container process to start, not for MySQL inside it to finish initializing and accept connections C. MySQL images never take time to start D. It is a Compose syntax error
B. It only waits for the db container process to start, not for MySQL inside it to finish initializing and accept connections — The container starting and the database service inside it being ready to accept connections are different moments; a healthcheck plus condition: service_healthy closes that gap explicitly.
Why is Docker Compose not suitable for running production traffic at scale? A. Compose files cannot define more than three services B. Compose is single-host by design, with no built-in cross-machine scheduling, automatic failover, or rolling updates across a fleet C. Compose cannot use named volumes D. Compose does not support environment variables
B. Compose is single-host by design, with no built-in cross-machine scheduling, automatic failover, or rolling updates across a fleet — Compose runs everything on the one machine executing it; scheduling containers across a cluster with failover and scaling is the job of an orchestrator like Kubernetes or ECS, not Compose.
Extend the docker-compose.yml example in this lesson by adding a fifth service called mailhog (image: mailhog/mailhog) for catching outgoing local emails, publishing its web UI on host port 8025 (container port 8025) with no healthcheck needed, and add a MAIL_HOST=mailhog environment variable to the php service.
mailhog: image: mailhog/mailhog ports: - "8025:8025" # and in the php service's environment block: # MAIL_HOST: mailhog mailhog needs a ports: mapping since a developer views its UI directly in a browser on the host; it doesn't need to be a dependency of php in the depends_on sense.