Docker Compose
A single docker run with a pile of flags is hard to maintain, and multiple containers more so. Docker Compose declares the whole app (services, networks, volumes) in one compose.yaml and brings it all up with one command.
The current version is docker compose (v2, a space, no hyphen), built into the Docker CLI. The old docker-compose (the Python v1) is EOL, no longer maintained, do not use it. A compose.yaml also does not need and should not include a version: top-level field; it is obsolete in the Compose Specification and only produces an obsolescence warning.
Click the keys in compose.yaml to see what each block does:
Top-level structure
name: myapp # project name (optional)
services: # required, the containers
web:
image: nginx
networks: # optional, custom networks
volumes: # optional, named volumes
configs: # optional, non-sensitive config
secrets: # optional, sensitive data
Common service keys
| Key | Purpose |
|---|
image | Pull a prebuilt image (pin a tag, not latest) |
build | Build from a Dockerfile (context / dockerfile / args / target) |
ports | Publish ports, HOST:CONTAINER |
environment / env_file | Env vars (inline / from file) |
volumes | bind mounts and named volumes |
depends_on | Startup order (with condition: service_healthy) |
healthcheck | Health-check command |
restart | Restart policy (no / always / on-failure / unless-stopped) |
networks | Which networks to join |
command / entrypoint | Override the image’s default command / entry point |
profiles | Group a service into a profile, off by default |
deploy | Resource limits and GPUs |
Creating networks
docker compose up automatically creates a project-specific bridge network (named <project>_default) and joins every service. A service name is its DNS hostname on that network, so web reaches the database at db:5432, no IP needed. To isolate tiers, declare your own networks:
services:
proxy:
networks: [frontend]
app:
networks: [frontend, backend]
db:
networks: [backend] # backend only, proxy cannot reach it
networks:
frontend:
driver: bridge
backend:
driver: bridge
proxy and db are on different networks and cannot talk directly; only app touches both.
Container group (project) name
Compose ties the set together with a “project name” and uses it as a naming prefix:
| Resource | Naming format | Example (project myapp, service web) |
|---|
| Container | <project>-<service>-<index> | myapp-web-1 |
| Network | <project>_<network> | myapp_default |
| Volume | <project>_<volume> | myapp_db-data |
Precedence (high to low): docker compose -p <name> > COMPOSE_PROJECT_NAME env var > top-level name: > the directory containing compose.yaml.
Environment configuration
| Form | Use for |
|---|
environment: | A few visible variables written inline, supports ${VAR} interpolation |
env_file: | Many variables, or secrets you keep out of the YAML and gitignore |
The project .env | Interpolation in compose.yaml itself (it does not auto-inject into the container) |
Interpolation: ${VAR}, ${VAR:-default} (fall back when unset or empty), ${VAR:?msg} (error out when unset). In-container env var precedence (high to low): docker compose run -e > environment: > env_file: > host shell inheritance > Dockerfile ENV.
Using GPUs
The official way is to declare it under a service’s deploy.resources.reservations.devices (the host needs the NVIDIA Container Toolkit first):
services:
trainer:
image: nvidia/cuda:12.9.0-base-ubuntu22.04
command: nvidia-smi
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all # or count: 1 / device_ids: ['0','3']
capabilities: [gpu] # required, deployment errors without it
depends_on with healthcheck: use condition: service_healthy to wait until a dependency is actually ready, so the app does not race a database that is not up yet.
- Resource limits:
deploy.resources.limits cpus / memory also take effect in standalone (non-Swarm) mode.
pull_policy and build cache: control whether to pull every time and reuse build cache.
- Startup:
up -d (background), --build (rebuild first), --scale web=3 (run 3 copies), --wait (wait until everything is healthy).
services:
app:
depends_on:
db:
condition: service_healthy
db:
image: postgres:18
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
Volume and bind mount mapping
Compose’s volumes: handles both bind mounts and named volumes, distinguished by how the source is written (see Where data lives):
services:
app:
volumes:
- ./src:/app/src # bind mount: relative host path
- ./config.yaml:/app/config.yaml:ro # read-only
- app-data:/var/lib/data # named volume (declared at the top level)
volumes:
app-data:
Use named volumes for data that must persist and bind mounts for live-syncing source. A path with no mount declared only writes to the container writable layer and is gone when the container is removed.
A complete compose.yaml
# compose.yaml — no version field needed or wanted (obsolete)
name: myapp
services:
web:
build:
context: .
target: production
ports:
- "8080:80"
environment:
DATABASE_URL: "postgresql://db:5432/${DB_NAME:-mydb}"
env_file:
- path: .env
required: false
volumes:
- ./static:/app/static:ro
- upload-data:/app/uploads
depends_on:
db:
condition: service_healthy
restart: unless-stopped
networks: [frontend, backend]
db:
image: postgres:18
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME:-mydb}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: unless-stopped
networks: [backend]
networks:
frontend:
driver: bridge
backend:
driver: bridge
volumes:
db-data:
upload-data:
Common docker compose commands
docker compose up -d # bring the whole set up in the background
docker compose up -d --build # rebuild then start
docker compose ps # containers in this project
docker compose logs -f web # follow the web service logs
docker compose exec web sh # shell into the web service
docker compose config # expand and validate the compose file (exit 0 on success)
docker compose build --no-cache # rebuild without cache
docker compose down # stop and remove containers and networks
Bringing a compose.yaml up looks like this:
docker compose down -v deletes named volumes (the ones declared in the volumes: section), and database data often lives there: once gone, it is gone. To only stop services and keep data, use docker compose down without -v.
Next
Reference: docs.docker.com/reference/compose-file, Compose GPU support