Docker Compose Deep Dive
Docker Compose lets us declare a multi-container application in one YAML file and bring it up with a single command. In this guide we'll cover the modern Compose Specification as implemented by Docker Compose v2 — meaning we use the docker compose command (space, not hyphen) and we do not put a version: key at the top of the file. The version field is obsolete and silently ignored by Compose v2.
Real-World Scenario
Spinning up a realistic dev environment.
You're onboarding a new engineer. Instead of a 30-step README about installing Postgres, Redis, the app, and an Nginx proxy, you hand them git clone and docker compose up. The same Compose file (with a small override) also runs in CI for integration tests. That's the workflow we'll build up to in this guide.
Top-Level Keys
A Compose file is structured around four main top-level keys:
services— the containers that make up your application.networks— user-defined networks the services attach to.volumes— named volumes for persistent data.secrets— secret values mounted into services (file-based outside of Swarm).
A minimal file looks like this:
compose.yaml — minimum viable
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
Filename note: Compose v2 looks for compose.yaml, compose.yml, docker-compose.yaml, or docker-compose.yml in the current directory, in that order. The modern preferred name is compose.yaml.
build vs image
A service can either pull a pre-built image (image:) or build one from a Dockerfile (build:). You can use both: build tells Compose how to construct the image, and image names the resulting tag.
Build patterns
services:
api:
# Build from ./api/Dockerfile and tag as myorg/api:dev
build:
context: ./api
dockerfile: Dockerfile
args:
APP_ENV: development
target: dev # multi-stage: stop at the `dev` stage
image: myorg/api:dev # name for the built image (useful for `docker push`)
cache:
# Pure pull, no build
image: redis:7-alpine
ports vs expose (and the security implication)
This trips people up constantly. They are not the same.
ports:publishes a container port on the host. Anyone who can reach the host on that port can reach the container.expose:only declares that a port is available to other containers on the same Compose network. It is not published on the host.
By default, ports: "5432:5432" binds to all interfaces (0.0.0.0) on the host. That means your database is reachable from the network — including from the internet if the host has a public IP and no firewall. Always bind to localhost for anything that should not leave the host:
Bind a database port to localhost only
services:
db:
image: postgres:16
ports:
- "127.0.0.1:5432:5432" # localhost-only — safe for dev
# NOT: "5432:5432" ← publishes on 0.0.0.0, reachable from LAN
If the database only needs to be reached by other services in the Compose file, drop ports entirely and use expose (or rely on the default — services on the same network can reach each other on any port without expose; it's mostly documentation).
environment vs env_file
Two ways to inject environment variables. Both work; the difference is where the values live.
Inline vs file-based env
services:
api:
image: myorg/api:dev
environment:
LOG_LEVEL: info
DATABASE_URL: postgres://app:app@db:5432/app
# Pull from host env if it's set, otherwise empty:
SENTRY_DSN: ${SENTRY_DSN:-}
worker:
image: myorg/api:dev
env_file:
- ./.env.worker # KEY=value per line, no quotes needed
Compose also automatically loads variables from a top-level .env file for substitution into the Compose file itself (e.g. ${IMAGE_TAG}). That is different from env_file, which injects variables into the container.
depends_on with healthchecks
The classic mistake: declaring depends_on: [db] and assuming the database is ready when the API starts. It isn't — depends_on by itself only waits for the container to start, not for the service inside to be healthy.
The fix is to declare a healthcheck on the dependency and use the long form of depends_on:
Wait until Postgres is actually accepting queries
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
api:
image: myorg/api:dev
depends_on:
db:
condition: service_healthy
Healthcheck fields:
test— command run inside the container.CMD-SHELLruns it through/bin/sh -c.interval— how often to run the check after it has started.timeout— how long a single check may take before counting as a failure.retries— consecutive failures before the container is marked unhealthy.start_period— grace period at startup during which failures don't count towardretries. Critical for slow-starting services like databases or JVM apps.
Restart Policies
Tell Docker what to do when a container exits.
Restart options
services:
api:
image: myorg/api:dev
restart: unless-stopped # restart on failure or daemon restart; honor manual stop
# other values:
# no — never restart (default)
# always — restart even if you `docker compose stop`-ed it (rare for dev)
# on-failure — restart only on non-zero exit; supports max-retries
For typical long-running services, unless-stopped is the safest default: it survives daemon restarts but still respects an explicit docker compose stop.
Volumes — Named vs Bind Mounts
Two distinct mount types. Use the right one for the job.
- Named volume (
my_volume:/var/lib/postgres) — managed by Docker, lives under/var/lib/docker/volumes/, survivesdocker compose down(but notdown -v). Use for persistent data (databases, uploads). - Bind mount (
./src:/app/src) — a host path mounted into the container. Use for source code in development so edits are reflected live.
Both kinds in one file
services:
api:
image: myorg/api:dev
volumes:
- ./src:/app/src # bind mount for live reload in dev
- api_node_modules:/app/node_modules # named volume, opaque to the host
db:
image: postgres:16
volumes:
- db_data:/var/lib/postgresql/data # named volume — persistent
volumes:
api_node_modules:
db_data:
⚠️ Destructive: docker compose down -v deletes named volumes too. Reach for it only when you want a clean slate.
Multiple Networks for Isolation
By default, Compose creates one network and attaches every service to it. For better isolation (especially in production-shaped setups), declare multiple networks and attach services only where they need to talk.
Frontend / backend network split
services:
proxy:
image: nginx:1.27-alpine
ports:
- "443:443"
networks: [frontend]
api:
image: myorg/api:dev
networks: [frontend, backend] # proxy can reach api; api can reach db
db:
image: postgres:16
networks: [backend] # NOT on frontend — proxy can't reach it
networks:
frontend:
backend:
Containers on the same network resolve each other by service name (e.g. the api can connect to postgres://db:5432). Containers on different networks cannot reach each other at all — exactly what we want for a database.
Profiles — Optional Services
Profiles let you mark services as optional and start them only when explicitly requested. Great for things like a debug shell, a one-off seed job, or a heavy monitoring stack.
Define and use a profile
services:
api:
image: myorg/api:dev
# no profile — always started
jaeger:
image: jaegertracing/all-in-one:1.57
profiles: ["tracing"] # only started when 'tracing' profile is active
ports:
- "16686:16686"
# Start the default set only:
# docker compose up
#
# Start including tracing:
# docker compose --profile tracing up
Secrets (file-based)
Compose secrets in non-Swarm mode are just files mounted read-only into the container at /run/secrets/<name>. They're cleaner than putting passwords in environment (which leaks into docker inspect and process listings).
File-based secret
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt # keep this out of git: add to .gitignore
Note: the full secrets feature (encrypted at rest, distributed across nodes) only applies to Swarm. In plain Compose this is essentially a tidy way to mount a file.
Override Files
Compose automatically merges compose.yaml with compose.override.yaml (or docker-compose.override.yml) if present. This is the canonical pattern for "same base, different environment."
The classic pattern: the base file is production-shaped, and the override turns on dev conveniences (bind mounts, exposed debug ports, looser env vars).
compose.yaml (base — production-shaped)
services:
api:
image: myorg/api:1.4.2
restart: unless-stopped
environment:
LOG_LEVEL: info
compose.override.yaml (auto-merged in dev)
services:
api:
build:
context: ./api
target: dev
image: myorg/api:dev
ports:
- "127.0.0.1:3000:3000"
volumes:
- ./api:/app
environment:
LOG_LEVEL: debug
DEBUG: "*"
For non-default override files (e.g. CI), pass them explicitly:
docker compose -f compose.yaml -f compose.ci.yaml up --abort-on-container-exit
Worked Example 1: nginx + app + postgres
A realistic small stack: an Nginx reverse proxy in front of a Node-style app, talking to a Postgres database. Both networks are isolated; the database is only reachable from the backend network and has a healthcheck.
compose.yaml
services:
proxy:
image: nginx:1.27-alpine
ports:
- "127.0.0.1:8080:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
app:
condition: service_healthy
networks: [frontend]
restart: unless-stopped
app:
build:
context: ./app
image: myorg/app:dev
environment:
DATABASE_URL: postgres://app:app@db:5432/app
PORT: "3000"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
db:
condition: service_healthy
networks: [frontend, backend]
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
networks: [backend]
restart: unless-stopped
networks:
frontend:
backend:
volumes:
db_data:
secrets:
db_password:
file: ./secrets/db_password.txt
Bring it up and watch the health states transition:
docker compose up -d docker compose ps docker compose logs -f app docker compose down # stop and remove containers/networks docker compose down -v # also remove the named volume (DESTROYS db_data)
Worked Example 2: Minimal Dev Override
This is the smallest useful override pattern: the base file is what you'd deploy, and the override enables a local bind mount and a debugger port.
compose.yaml (base)
services:
api:
image: myorg/api:1.4.2
restart: unless-stopped
environment:
NODE_ENV: production
compose.override.yaml (picked up automatically)
services:
api:
build:
context: ./api
target: dev
image: myorg/api:dev
command: npm run dev
volumes:
- ./api:/app
- /app/node_modules # anonymous volume so host's node_modules doesn't clobber container's
ports:
- "127.0.0.1:3000:3000"
- "127.0.0.1:9229:9229" # Node inspector
environment:
NODE_ENV: development
To run the production-shaped base without the dev override (e.g. to test the production image locally):
docker compose -f compose.yaml up
Useful Day-to-Day Commands
The Compose v2 cheat sheet
# Bring everything up in the background docker compose up -d # Rebuild images and restart docker compose up -d --build # See what's running and the health status docker compose ps # Stream logs (all services or a single one) docker compose logs -f docker compose logs -f api # Open a shell in a running container docker compose exec api sh # Run a one-off command (new container, removed afterwards) docker compose run --rm api npm test # Validate & render the merged config (great for debugging overrides) docker compose config # Pull updated images for all services docker compose pull # Stop everything cleanly docker compose down # Nuke containers, networks, AND named volumes (DESTRUCTIVE) docker compose down -v
Summary
- ✓ Modern Compose: no
version:key, usedocker compose(v2). - ✓ Top-level keys:
services,networks,volumes,secrets. - ✓
portspublishes on the host (default 0.0.0.0 — bind to127.0.0.1for dev DBs);exposeis documentation only. - ✓ Use
healthcheck+depends_on.condition: service_healthyto wait for real readiness. - ✓ Named volumes for persistent data, bind mounts for source code.
- ✓ Multiple networks isolate sensitive services from the public-facing ones.
- ✓ Profiles for optional services; overrides for environment-specific tweaks.
- ✓ Prefer file-based secrets over env vars for passwords.
Compose is the right tool for local dev environments, integration tests, and small single-host deployments. For multi-host orchestration, graduate to Kubernetes or Swarm — but the mental model you build here transfers cleanly.