Docker Compose defines and runs multi-container applications. It's the de-facto standard for local dev environments and is sometimes used for small production deployments too. This guide walks through the structure of docker-compose.yml, what each of the five top-level keys does, how depends_on differs from healthchecks, named volumes vs bind mounts, and the gotchas that break dev environments.
The five top-level keys
# v3+ format — the version key is no longer required
services: # 1. container definitions (required)
networks: # 2. network definitions
volumes: # 3. named volume definitions
configs: # 4. Swarm configs (optional)
secrets: # 5. Swarm secrets (optional)Every manifest revolves around services. The other four exist only when services reference them; they live in the same file.
services — container definitions
services:
web:
image: nginx:1.27 # image or build
container_name: my-web # optional explicit name
ports:
- "80:80" # host:container
- "443:443"
environment: # env vars
LOG_LEVEL: info
DATABASE_URL: postgres://db/app
env_file: # pull from .env file
- .env
depends_on: # start order
- db
networks: # which networks to attach
- frontend
volumes: # which paths to mount
- ./static:/usr/share/nginx/html
- logs:/var/log/nginx
restart: unless-stopped # restart policy
healthcheck: # health probe
test: ["CMD", "curl", "-f", "http://localhost"]
interval: 30s
timeout: 3s
retries: 3The service name (web above) becomes the container's DNS name. Other services in the same Compose can reach it via http://web — no DNS configuration needed.
image vs build
- image: pull from Docker Hub or a private registry.
image: postgres:16. - build: build from a local Dockerfile.
build: ./apiorbuild: {context: ., dockerfile: Dockerfile.dev}.
With both, build wins and the resulting image is tagged with the image value. Use one or the other normally.
networks — service isolation
networks:
frontend:
driver: bridge # default
backend:
driver: bridge
internal: true # no external access (internal only)
monitoring:
external: true # an existing network outside ComposeServices see each other only on shared networks. Above: db on backend is unreachable from web on frontend. A natural DMZ pattern.
Compose creates a default network automatically (<project>_default). Without explicit networks: in services, everything joins this single network.
volumes — persistent data
Named volume (recommended)
services:
db:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data # named: mount
volumes:
db-data: # top-level declaration- Managed by Docker. Stored at
/var/lib/docker/volumes/<project>_db-data/. docker compose downremoves containers but keeps volumes.docker compose down -vexplicitly deletes them.- Backup- and migration-friendly. Portable across hosts.
Bind mount (mount host filesystem)
volumes:
- ./src:/app/src # host ./src → container /app/src
- /etc/timezone:/etc/timezone:ro # read-only- Mounts a host filesystem path.
- Essential for hot reload in dev environments.
- Risk of OS- and permission-mismatch. Not recommended for production.
tmpfs (in memory)
services:
api:
tmpfs:
- /tmp # RAM-backed; cleared on restartTo see structure at a glance, drop your manifest into Docker Compose Visualizer — named volumes show as nodes connected to their services. Bind mounts are excluded (host-dependent, not Compose-managed).
depends_on — start order ≠ readiness
services:
api:
depends_on:
- db
db:
image: postgres:16Compose starts db first, then api. But nothing guarantees db is ready to accept connections. Container started ≠ process ready.
Fix: long form with a condition that waits on healthcheck.
services:
api:
depends_on:
db:
condition: service_healthy # wait until db's healthcheck passes
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5Condition values:
service_started(default) — container is upservice_healthy— healthcheck passesservice_completed_successfully— exited successfully (Job pattern)
environment vs env_file vs secrets
environment
environment:
- LOG_LEVEL=info # array form
- DATABASE_URL=postgres://...
# or map form
environment:
LOG_LEVEL: info
DATABASE_URL: postgres://...Plain text in the manifest. Never put secrets here — committing the file leaks them.
env_file
env_file:
- .env
- .env.localLoad variables from a .env file (which should be gitignored). Standard for dev environments.
secrets (Swarm)
services:
db:
secrets:
- db-password
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db-password
secrets:
db-password:
file: ./secrets/db-password.txtThe secret file mounts as /run/secrets/<name>. Safer than env vars — not in the process environment, not visible to docker inspect.
ports — host : container
ports:
- "8080:80" # host 8080 → container 80
- "127.0.0.1:8080:80" # bind to localhost (no external access)
- "8080:80/udp" # UDP
- "8000-8010:8000-8010" # rangesports is for external exposure. Services within the same Compose talk to each other over the default network using DNS — no ports required. Ports are for the host and external clients.
Real-world — a full-stack dev environment
services:
web:
build: ./web
ports: ["3000:3000"]
volumes:
- ./web/src:/app/src # hot reload
depends_on:
api:
condition: service_started
environment:
NEXT_PUBLIC_API_URL: http://localhost:8000
api:
build: ./api
ports: ["8000:8000"]
volumes:
- ./api:/app
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
db:
image: postgres:16
ports: ["5432:5432"] # for local DBeaver
volumes:
- db-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 5
cache:
image: redis:7-alpine
ports: ["6379:6379"]
volumes:
db-data:To see how the manifest connects together, paste the above into Docker Compose Visualizer.
Essential commands
docker compose up -d # background start
docker compose up --build # rebuild images and start
docker compose down # remove containers + networks
docker compose down -v # ... also remove volumes (careful)
docker compose ps # status
docker compose logs -f api # follow a service's logs
docker compose exec api bash # shell into a container
docker compose restart api # restart one service
docker compose pull # refresh imagesCompose vs Kubernetes
| Axis | Docker Compose | Kubernetes |
|---|---|---|
| Target | Single host, local dev | Cluster, production |
| Learning curve | Mild (hours) | Steep (weeks) |
| HA / scaling | Limited (Swarm mode) | First-class (Deployment / HPA) |
| Manifests | One file (compose.yml) | Many (Helm / Kustomize) |
For Compose → K8s migration, kompose convert automates the mapping. With Kubernetes YAML Visualizer and Docker Compose Visualizer sharing the same visualization pattern, side-by-side structure comparison is intuitive.
Common pitfalls
1. Assuming depends_on means ready
API starts connecting to DB immediately; DB is still booting → connection refused. Healthcheck + condition: service_healthy is required.
2. Secrets in environment
Committing the manifest leaks them. Use env_file + .env (gitignored) or proper secrets.
3. Bind mount permissions
Host directory permissions don't match the container user → write fails. macOS/Windows Docker Desktop handles this automatically; Linux usually requires user: "${UID}:{GID}".
4. Setting version: "3"
Compose v2+ (the CLI itself) ignores the version: key and warns. Modern manifests omit it.
5. Two services on the same host port
Two services with ports: ["8080:80"] — the second fails. Host port collision. Pick different ports or only expose one.
6. Implicit shared network
Without explicit networks:, every service sits on the default network. Zero isolation. For production or security-sensitive setups, split into named networks.
7. docker compose down -v mistake
Wipes named volumes too. DB data gone. Common dev accident — have a backup policy.
8. v1 vs v2 command confusion
Old docker-compose (hyphen, Python) vs new docker compose (space, Go plugin). Always use the latter in new environments — the old command is deprecated as of 2024.
Summary
- Five top-level keys: services / networks / volumes / configs / secrets. Services is the hub.
- Service name = container DNS name. Same-network services auto-discover.
- depends_on guarantees start order only. Readiness needs healthcheck + condition: service_healthy.
- Named volumes = Docker-managed, bind mounts = host-dependent. Production prefers named.
- Never put secrets in environment. Use env_file (.env gitignored) or secrets.
- For an instant map of any compose file, paste it into Docker Compose Visualizer.