docker-compose.yml #
The docker-compose.yml file is the document that defines your multi-container application stack. It’s YAML with a hierarchical structure that splits the configuration into logical sections: services (mandatory), volumes, networks, configs, and secrets.
This article covers the anatomy of docker-compose.yml thoroughly — what the top-level keys are, how they relate to each other, and the conventions worth following to keep the file readable and maintainable.
Top-Level Keys #
The most complete docker-compose.yml file has several top-level keys. Some are mandatory, some optional.
# docker-compose.yml
version: "3.8" # optional in Compose V2
services: # MANDATORY
web:
...
api:
...
volumes: # optional
db-data:
networks: # optional
backend:
configs: # optional, for configuration files
nginx.conf:
secrets: # optional, for sensitive data
db-password:
services — Mandatory #
services is the only mandatory top-level key. It contains the list of services to run. Each service represents one container (or several containers if scaled).
volumes — For Persistent Data #
volumes declares the volumes services use. If a service has a volumes: field referencing an undeclared volume, Compose automatically creates it. But the best practice is explicit declaration at the top level.
networks — For Isolation #
networks declares the networks services use. By default, Compose creates one network for all services in the file. For more advanced configuration, you can define several networks.
configs — For Configuration Files #
configs lets you mount files or directories into containers. The difference from bind mounts: configs are defined in the Compose file and injected at runtime.
secrets — For Sensitive Data #
secrets is similar to configs but for sensitive data like passwords, API keys, and certificates. Secrets are stored in Docker’s secret storage (encrypted in production) and mounted to /run/secrets/ in the container.
Schema version: Since Docker Compose V2, the top-levelversion:field is optional and deprecated. If you use Compose V2, dropversion:from the file. This makes the file more future-proof and avoids “unknown version” errors.
Service Anatomy #
Each service under services: has many fields. Not all are mandatory, but each has a specific role.
Basic Fields #
services:
web:
image: nginx:alpine # the image to use
# OR
build: ./web # path to a Dockerfile
container_name: my-nginx # optional, container name
hostname: web-server # optional, hostname inside the container
ports:
- "8080:80" # port mapping
environment:
- NODE_ENV=production # env vars
- DEBUG=false
volumes:
- ./html:/usr/share/nginx/html:ro
- db-data:/var/lib/data
networks:
- frontend
- backend
depends_on:
- api
- db
restart: unless-stopped
user: "1000:1000"
working_dir: /app
command: ["nginx", "-g", "daemon off;"]
entrypoint: ["/docker-entrypoint.sh"]
Image vs Build #
A service can be specified with image: (use an image directly from the registry) or build: (build from a local Dockerfile).
services:
# Use an image from Docker Hub
redis:
image: redis:7-alpine
# Build from a local Dockerfile
api:
build:
context: ./api
dockerfile: Dockerfile.dev
args:
- NODE_ENV=development
# Build AND tag
api-prod:
build: ./api
image: myregistry.com/api:v1.2.3
When using build:, the resulting image is stored in the local Docker cache. For multi-stage builds, Compose automatically uses the stage specified in the Dockerfile (usually the last stage).
Ports #
The ports field maps host ports to container ports.
ports:
- "8080:80" # host:container
- "127.0.0.1:8080:80" # host_ip:host_port:container_port
- "8443:443" # HTTPS
- "3000" # random host port, expose 3000
The short "HOST:CONTAINER" format is the most common. For special cases (binding to a specific IP), use the long format.
For production, avoid- "80:80"or other low host ports. Ports below 1024 need root to bind. Use high host ports (e.g."8080:80") then a reverse proxy.
Environment Variables #
services:
api:
environment:
- NODE_ENV=production
- DATABASE_URL=postgres://user:pass@db:5432/myapp
- DEBUG=false
# Or use map syntax
environment:
NODE_ENV: production
DATABASE_URL: postgres://user:pass@db:5432/myapp
# Use an .env file
env_file:
- .env
- .env.production
env_file reads an .env file and injects its variables into the service. The .env file should be in .gitignore if it contains secrets.
Volumes #
The volumes field has several formats.
services:
web:
volumes:
# Named volume
- db-data:/var/lib/data
# Bind mount
- ./html:/usr/share/nginx/html
# Bind mount with options
- ./html:/usr/share/nginx/html:ro
# Anonymous volume
- /var/lib/data
# tmpfs
- type: tmpfs
source: /tmp
target: /tmp
tmpfs:
size: 100m
The source:target:options format is the most explicit and recommended. The short - source:target format is also valid and often used.
Networks #
Services automatically join the default network Compose creates. For custom networks:
services:
web:
networks:
- frontend
- backend
networks:
frontend:
backend:
driver: bridge
driver_opts:
com.docker.network.bridge.name: br-backend
The web service now has two network interfaces. It can communicate with other services on both frontend and backend. Services on backend can’t be reached from frontend services unless also exposed there.
Depends On #
depends_on sets the startup order.
services:
web:
depends_on:
- api
api:
depends_on:
- db
- redis
In the example above, db and redis start first, then api, then web. But — importantly — depends_on only waits for containers to start, not for services to be ready to accept requests.
To truly wait until a service is ready (e.g. the database accepting connections), use healthcheck + depends_on: { ... condition: service_healthy }.
services:
api:
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
api now won’t start until db’s healthcheck returns healthy. This is more reliable for dependencies that need initialization time.
Restart Policy #
services:
web:
restart: "no" # default
restart: always # always restart
restart: on-failure # restart only on non-zero exit codes
restart: unless-stopped # restart unless manually stopped
For production, unless-stopped or always are safe choices. For development, no (default) is usually enough since you start/stop things yourself.
User, Working Directory, Command #
services:
app:
user: "1000:1000" # run as non-root
working_dir: /app
command: ["python", "app.py"]
entrypoint: ["/entrypoint.sh"]
command overrides CMD in the Dockerfile. entrypoint overrides ENTRYPOINT. user makes the container run as a specific user (not root) for security.
Resource Limits #
services:
api:
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
reservations:
cpus: "0.25"
memory: 256M
deploy is a field commonly used in Docker Swarm. For single-host Compose, you can use deploy.resources (supported in Compose V2) or use mem_limit and cpus at the top level (legacy).
A Complete File Example #
A docker-compose.yml for a simple e-commerce stack:
services:
web:
build: ./web
ports:
- "3000:3000"
environment:
- API_URL=http://api:8080
depends_on:
api:
condition: service_healthy
networks:
- frontend
restart: unless-stopped
api:
build: ./api
environment:
- DATABASE_URL=postgres://user:pass@db:5432/shop
- REDIS_URL=redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
networks:
- frontend
- backend
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=shop
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d shop"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
restart: unless-stopped
cache:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
networks:
- backend
restart: unless-stopped
proxy:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- web
networks:
- frontend
restart: unless-stopped
volumes:
db-data:
networks:
frontend:
backend:
This stack has 5 services: web (frontend), api (backend), database, cache, and a reverse proxy. Networks are split into frontend and backend — web and proxy on frontend, database and cache on backend, which isn’t exposed to the host.
Writing Conventions #
Indentation and Formatting #
Use 2-space indentation (the YAML standard). Don’t use tabs. The Compose parser rejects tabs.
# GOOD
services:
web:
image: nginx
ports:
- "8080:80"
# BAD
services:
web:
image: nginx
ports:
- "8080:80"
Line Length #
Try to keep lines under 120 characters so they’re easy to read in code reviews.
Comments #
Use comments to explain WHY, not WHAT. Code explains what; comments explain why.
services:
api:
# PostgreSQL tuned for high-concurrency workloads
image: postgres:16-alpine
command: postgres -c max_connections=200
Service Order #
There’s no fixed rule, but some good conventions:
- Most important services first (e.g. web, api)
- Databases/caches at the end
- Helper services (proxy, monitoring) by role
- Group related services together
Naming Consistency #
- Services: kebab-case (
user-service,auth-service) - Volumes: kebab-case (
user-uploads,app-cache) - Networks: descriptive (
frontend,backend,monitoring)
Multiple Compose Files #
For different environments, you can override with additional files.
# Default + development override
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# Default + production override
docker compose -f docker-compose.yml -f docker-compose.prod.yml up
Override files overwrite fields in the default. This lets the same base configuration serve many environments.
Validation #
Before running, validate your Compose file.
# Show the parsed config
docker compose config
# Show and highlight issues
docker compose config --quiet
docker compose config reads all files (default + override + env file), merges, parses, then shows the result. Any syntax errors or invalid references appear here.
The --quiet flag only shows errors and warnings without the full output — great for a CI/CD validation step.
Advanced References #
Profiles #
Docker Compose has a profiles feature that lets you group services by purpose (development, testing, production). Services with a profile only run when that profile is activated.
services:
web:
image: nginx
ports:
- "80:80"
debug-tools:
image: alpine
profiles: ["debug"]
command: sleep infinity
load-generator:
image: loadtest
profiles: ["load-test"]
# Default — debug-tools and load-generator don't run
docker compose up
# With the debug profile
docker compose --profile debug up
# With the load-test profile
docker compose --profile load-test up
Profiles are very useful for development environments where you sometimes need extra tools (debuggers, log viewers, mock servers) without adding them to the default setup.
Variable Substitution #
YAML supports variable substitution from the host environment.
services:
api:
image: myapp:${VERSION:-latest}
ports:
- "${PORT:-8080}:8080"
environment:
- LOG_LEVEL=${LOG_LEVEL}
${VAR} takes a value from the environment. ${VAR:-default} uses a default value if VAR isn’t set. Compose reads the host environment when parsing the file.
Multiple Compose Files and Merging #
Field overriding when using multiple files:
docker compose -f base.yml -f override.yml up
When override.yml has a service matching one in base.yml, the override’s fields overwrite the base’s. But lists and maps are merged, not replaced.
# base.yml
services:
web:
environment:
- LOG_LEVEL=info
- NODE_ENV=production
# override.yml
services:
web:
environment:
- LOG_LEVEL=debug
Result: LOG_LEVEL=debug (overridden) + NODE_ENV=production (preserved).
JSON Syntax #
Compose also supports JSON as a YAML alternative. Useful if YAML isn’t available in your toolchain.
{
"services": {
"web": {
"image": "nginx",
"ports": ["80:80"]
}
}
}
But YAML is more readable for complex configuration, so JSON is rarely used except for programmatically generated configs.
Supporting tools: Some IDEs have extensions for docker-compose.yml — auto-complete, validation, and service-dependency visualization. Recommendation: VSCode with the “Docker” or “Kubernetes” extension (also supports Compose).
Summary #
docker-compose.ymlis a declarative YAML file defining your entire multi-container application stack.- Top-level keys:
services(mandatory),volumes,networks,configs,secrets. Theversion:field is optional in Compose V2.- Each service has many fields:
image/build,ports,environment,volumes,networks,depends_on,restart,command, etc.depends_on≠ service ready —depends_ononly waits for containers to start. To truly wait for readiness, use healthchecks +condition: service_healthy.- Network isolation — split services across several networks (frontend, backend) to restrict communication.
- Resource limits matter in production to prevent one service from consuming all resources.
- Validate with
docker compose configbefore running, to catch syntax errors and invalid references.- Override files (
-f docker-compose.dev.yml) let the same base configuration serve many environments.- Conventions: 2-space indentation, no tabs, comments explain why, descriptive service and volume names.