Docker Compose: The Complete Guide
Docker Compose lets you define and run multi-container Docker applications with a single command. Instead of remembering and typing multiple docker run commands every time you start your development environment, you declare all services, networks, volumes, and environment variables in a YAML file and launch everything with docker compose up.
For development teams, Docker Compose solves the “it works on my machine” problem. Every developer gets the same database, cache, API server, and frontend running in identical containers, eliminating discrepancies between environments. For production, Compose simplifies deployment of modest multi-service applications and serves as a stepping stone to full orchestration with Docker Swarm or Kubernetes.
What Is Docker Compose?
Instead of running multiple docker run commands:
docker run -d --name db postgres:16
docker run -d --name api --link db my-api
docker run -d --name frontend --link api nginxYou define everything in a YAML file:
# docker-compose.yml
services:
db:
image: postgres:16
api:
build: ./api
depends_on:
- db
frontend:
build: ./frontend
ports:
- "80:80"And start everything with one command: docker compose up. Docker Compose creates an isolated network for your application where services can reach each other by their service name (e.g., api can connect to db using db as the hostname). No more --link flags, manual network creation, or juggling container IDs.
Basic Structure
A Compose file has three top-level sections — services, networks, and volumes:
services:
# Define your containers here
networks:
# Define custom networks
volumes:
# Define persistent volumesThe services section defines each container image, build context, ports, environment variables, volumes, and dependencies.
Defining Services
Each service maps to a container. Here is the full range of configuration options:
services:
web:
build: . # build from Dockerfile
image: my-app:latest # or use existing image
ports:
- "8000:8000" # host:container
environment:
- DEBUG=true
- DB_HOST=db
env_file:
- .env
volumes:
- ./src:/app/src # bind mount
- app_data:/data # named volume
depends_on:
- db
- redis
restart: unless-stoppedUse build when your service has a custom Dockerfile and image when pulling a pre-built image from a registry. You can combine both — the image field then tags the built image, which is useful when you want to push it to a registry after building.
Environment Variables
Compose supports three ways to pass environment variables:
- Inline with
environment:(good for static, non-sensitive values). - File-based with
env_file:(good for grouping related variables). - Shell variable substitution in the YAML itself:
${DB_PASSWORD}(good for secrets loaded from.env).
Never hardcode secrets in docker-compose.yml. Use .env files (auto-loaded from the project directory) or Docker secrets for production.
Docker Compose in Action
Development
For development, prioritize hot-reload and debuggable configurations:
services:
api:
build:
context: ./api
target: development
volumes:
- ./api:/app # live reload
environment:
- DEBUG=1
ports:
- "8000:8000"
command: uvicorn app:app --reload --host 0.0.0.0Binding your source code as a volume (./api:/app) lets you edit files on your host and see changes reflected inside the container immediately. Combined with a development server that supports auto-reload (like uvicorn, nodemon, or webpack-dev-server), you get the same developer experience as running natively.
Production
In production, remove bind mounts, enable health checks, set restart policies, and use production-optimized Dockerfiles:
services:
api:
build:
context: ./api
target: production
ports:
- "8000:8000"
environment:
- DEBUG=0
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3Health checks let Docker know when a service is actually ready. Docker Compose waits for a service’s health check to pass before starting dependent services when depends_on includes condition: service_healthy. This is critical for services that must wait for a database to be ready before accepting connections.
Complete Example
A typical web application with PostgreSQL, Redis, a Python API, and Nginx:
services:
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: myapp
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
redis:
image: redis:7-alpine
api:
build: ./api
depends_on:
- db
- redis
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/myapp
REDIS_URL: redis://redis:6379
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- api
volumes:
pgdata:Named volumes (like pgdata) persist data across container restarts. Bind mounts (like ./nginx.conf:...) are convenient for configuration files but make the container dependent on the host filesystem. For production, copy configuration into the image at build time or use Docker configs.
Service Discovery
Within a Compose project, services resolve each other by name. The API connects to db:5432 and redis:6379 using the service names from the YAML file. Docker Compose sets up a default network where all services can communicate, or you can define multiple isolated networks.
Common Commands
Manage the application lifecycle:
docker compose up # create and start
docker compose up -d # in background
docker compose down # stop and remove
docker compose down -v # remove volumes too
docker compose logs # view logs
docker compose logs -f api # follow logs for a service
docker compose ps # list running services
docker compose exec api bash # shell into a service
docker compose build # rebuild images
docker compose pull # pull latest images
docker compose restart # restart all services
docker compose restart api # restart a single serviceUseful Variants
docker compose up --build— rebuild images before starting (equivalent tobuildthenup).docker compose up --scale api=3— run three instances of the API service (useful for load testing).docker compose down --remove-orphans— remove containers for services no longer in the Compose file.docker compose config— validate and view the resolved Compose configuration with all.envsubstitutions applied.
Multiple Environments
Use multiple Compose files to override settings per environment:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up# docker-compose.override.yml (auto-loaded for development)
services:
api:
volumes:
- ./api:/app
environment:
- DEBUG=1# docker-compose.prod.yml
services:
api:
restart: always
environment:
- DEBUG=0Docker Compose automatically loads docker-compose.override.yml alongside docker-compose.yml. The override file merges settings on top of the base file. For production, explicitly specify -f flags to avoid loading the development override. You can chain as many files as needed — later files override earlier ones.
Networks
By default, Compose creates one network for all services. Define multiple networks for better isolation:
services:
api:
networks:
- frontend
- backend
db:
networks:
- backend
networks:
frontend:
driver: bridge
backend:
driver: bridgePutting databases on a backend-only network and reverse proxies on a frontend network reduces the attack surface — even if the API is compromised, the database process is still isolated from external access. This multi-network pattern is standard practice for production deployments.
Best Practices
Use
.envfiles — never hardcode secrets in docker-compose.yml. Reference them with${VARIABLE}syntax and include a.env.examplefile in version control showing the required variables with placeholder values.Specify restart policies —
unless-stoppedfor production ensures containers restart after crashes or host reboots. Useno(default) for one-off tasks andalwaysfor essential services.Use healthchecks — ensures services are actually ready before dependents start. Without health checks,
depends_ononly waits for a container to start, not for the process inside to accept connections.Pin image versions —
postgres:16-alpine, notpostgres:latest. Tagged versions prevent unexpected breaking changes when upstream images are updated. Use Dependabot or Renovate to automatically update tags.Keep dev and prod close — minimize differences between environments. Use multi-stage builds (
target: developmentvstarget: production) to address differences while sharing the same Dockerfile.Use
.dockerignore— don’t send unnecessary files to the builder. Ignorenode_modules,.git,__pycache__,.env, and build artifacts. This speeds up builds and reduces the security surface.Set resource limits — prevent a runaway container from starving the host.
Use profiles — define optional services (like admin tools, testing databases) that only start when you pass the profile name:
docker compose --profile dev up.
FAQ
What is the difference between docker-compose (v1) and docker compose (v2)?
docker-compose (with hyphen) is the original Python-based v1 tool. docker compose (without hyphen) is the newer v2 plugin integrated into the Docker CLI. Use docker compose for new projects — it is faster and receives active development.
How do I debug why a service isn’t starting?
Run docker compose logs <service-name> to see the service’s output. Add --no-log-prefix for cleaner output. For interactive debugging, run docker compose run --rm <service-name> sh to start a shell instead of the default command.
Can I use Docker Compose in production?
Yes, but Compose is designed for single-host deployments. For multi-host production, use Docker Swarm or Kubernetes. Compose files can be translated to Swarm stacks with minimal changes.
How do I wait for a database to be ready before starting my app?
Use depends_on with condition: service_healthy combined with a health check on the database service. Alternatively, add a retry loop in your application startup code.
Why are my environment variables not being substituted?
Ensure you have a .env file in the same directory as your docker-compose.yml. Shell variable expansion uses ${VARIABLE} syntax. For literal $ characters, use $$.
Related: Start with Docker for beginners and learn Dockerfile best practices.