On this page
- The mental model
- A minimal, correct service
- Images vs. build
- Ports, volumes, and bind mounts
- Environment variables and env files
- Networks and service discovery
- Dependencies and health checks
- Restart policies
- Secrets and configs
- Profiles
- Resource constraints and logging
- Multiple Compose files: the override pattern
- Essential CLI
- Production stacks you can run
- Backups and persistent data
- Security hardening
- Troubleshooting
- Production checklist
- Frequently asked questions
- Related resources
Docker Compose is the shortest path from “some containers” to “a system.” It turns a directory of services — an app, a database, a cache, a reverse proxy — into a single declarative file you can bring up, tear down, and version-control. This guide takes Compose all the way to production: not just the syntax, but health checks, dependency ordering, secrets, resource limits, and the override pattern that keeps dev and prod in one place without leaking dev settings into production.
The mental model
A Compose file describes a set of services (each becomes one or more containers), the networks they talk over, and the volumes that outlive them. Compose’s job is to reconcile that description into running containers, in dependency order, on a shared network where services find each other by service name via Docker’s built-in DNS.
compose.yaml
├── services: the containers (app, db, cache, proxy)
├── networks: how services reach each other (default: one bridge network)
├── volumes: named, persistent storage that survives `down`
├── secrets: files mounted read-only into containers (not env vars)
└── configs: non-secret config files mounted into containers
The most important consequence: services reach each other by name, not localhost. Your app connects to db:5432, not 127.0.0.1:5432, because db resolves to the database container’s IP on the shared network.
A minimal, correct service
# compose.yaml
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80" # host:container — reachable at http://localhost:8080
restart: unless-stopped
docker compose up -d # create + start in the background
docker compose ps # what's running
docker compose logs -f web # follow logs
docker compose down # stop + remove containers and the default network
That’s a complete, valid Compose project. Everything below is about making services depend on each other correctly, persist data, stay secure, and survive restarts.
Images vs. build
A service either pulls a prebuilt image: or builds one from a build: context — or both (build, then tag as image:).
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
NODE_ENV: production
image: myorg/app:1.4.0 # tag the built image
pull_policy: missing
Ports, volumes, and bind mounts
- Ports map host↔container:
"8080:80". Omit the host side ("80") to publish on a random host port. Only publish what needs to be external — internal services (a database) should talk over the Compose network and expose no host port. - Named volumes are Docker-managed persistent storage. Use them for databases and any data that must survive
docker compose down. - Bind mounts map a host path into the container. Great for local dev (live-editing source); risky in production (host coupling, permissions).
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data # named volume — survives `down`
# NOTE: no `ports:` — the DB is only reachable by other services on the network
app:
build: .
volumes:
- ./src:/app/src:ro # bind mount for dev (read-only)
volumes:
pgdata: # declare the named volume
Environment variables and env files
Three layers, in increasing separation from the file:
services:
app:
environment:
- LOG_LEVEL=info # inline (fine for non-secret config)
- DATABASE_URL=${DATABASE_URL} # interpolated from the shell / .env
env_file:
- .env.app # bulk load from a file (not committed)
Compose auto-loads a .env file next to compose.yaml for ${VAR} interpolation. Keep real secrets out of it in production — env vars leak into docker inspect, logs, and child processes. Prefer secrets for credentials.
Networks and service discovery
By default Compose creates one network and puts every service on it; they reach each other by service name. Split networks to isolate tiers — e.g. keep the database off the public-facing network.
services:
proxy:
image: nginx:1.27-alpine
networks: [frontend]
app:
build: .
networks: [frontend, backend] # bridges the two tiers
db:
image: postgres:16
networks: [backend] # unreachable from `proxy`
networks:
frontend:
backend:
Here proxy can reach app, and app can reach db, but proxy cannot reach db at all — a simple, effective segmentation you get for free.
Dependencies and health checks
depends_on controls start order, but by default it only waits for the container to start, not to be ready. A database container is “started” long before Postgres is accepting connections. Use a health check plus condition: service_healthy to wait for actual readiness.
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
volumes:
- pgdata:/var/lib/postgresql/data
app:
build: .
depends_on:
db:
condition: service_healthy # wait until pg_isready passes
restart: unless-stopped
volumes:
pgdata:
secrets:
db_password:
file: ./secrets/db_password.txt
Restart policies
restart: unless-stopped # restart on failure and on daemon start, but not if you manually stopped it
Options: no (default), on-failure[:max], always, unless-stopped. For long-running services, unless-stopped is the usual production choice — it self-heals but respects a deliberate docker compose stop.
Secrets and configs
Compose secrets mount a file read-only at /run/secrets/<name> — never an env var, never in inspect. Many official images accept a *_FILE variant (e.g. POSTGRES_PASSWORD_FILE) exactly for this.
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
secrets:
db_password:
file: ./secrets/db_password.txt # gitignored; 0600 on disk
Configs work the same way for non-secret files (an nginx.conf, a prometheus.yml) — mounted read-only, version-controlled, no baking into the image.
Profiles
Profiles let one file describe optional services that only start when their profile is active — dev tooling, seeders, a debug proxy.
services:
app:
build: .
adminer:
image: adminer
profiles: [dev] # only starts with: docker compose --profile dev up
Resource constraints and logging
Unbounded containers can starve the host; unbounded logs can fill the disk. Set both.
services:
app:
build: .
deploy:
resources:
limits: { cpus: "1.0", memory: 512M }
reservations: { memory: 256M }
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" } # cap log growth
Multiple Compose files: the override pattern
Compose merges compose.yaml + compose.override.yaml automatically. Keep the base production-safe, and put dev-only conveniences (bind mounts, exposed debug ports, adminer) in the override — which you don’t apply in production.
# Dev: base + override merged automatically
docker compose up -d
# Production: base + an explicit prod file, ignoring the dev override
docker compose -f compose.yaml -f compose.prod.yaml up -d
This is the cleanest way to avoid the two classic failure modes: leaking dev settings (a bind-mounted source tree, an exposed database port) into prod, or maintaining two divergent files that drift apart.
Essential CLI
Docker Compose CLI — searchable
| Command | What it does | Risk |
|---|---|---|
docker compose up -d | Create and start services in the background. | Safe |
docker compose down | Stop and remove containers + default network (keeps volumes). | Caution |
docker compose down -v | Also remove named volumes — DELETES persistent data. | Destructive |
docker compose ps | List services and their state/health. | Safe |
docker compose logs -f <svc> | Follow a service's logs. | Safe |
docker compose exec <svc> sh | Open a shell in a running service container. | Safe |
docker compose config | Render + validate the merged, interpolated config. | Safe |
docker compose build | Build (or rebuild) service images. | Safe |
docker compose pull | Pull the latest images for pinned tags. | Safe |
docker compose restart <svc> | Restart a service without recreating it. | Safe |
docker compose up -d --force-recreate | Recreate containers even if config is unchanged. | Caution |
No commands match that filter.
Production stacks you can run
Reverse proxy + application
services:
proxy:
image: nginx:1.27-alpine
ports: ["80:80"]
configs:
- source: nginx_conf
target: /etc/nginx/conf.d/default.conf
depends_on:
app: { condition: service_healthy }
networks: [frontend]
restart: unless-stopped
app:
build: .
expose: ["3000"] # internal only — no host port
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 10s
retries: 3
networks: [frontend, backend]
restart: unless-stopped
configs:
nginx_conf:
file: ./nginx.conf
networks:
frontend:
backend:
PostgreSQL + application (with readiness + secrets)
Combine the health-check + secrets pattern from above: db gates app via condition: service_healthy, the password is a mounted secret, and pgdata persists the database. That single pattern — readiness-gated, secret-driven, volume-backed — is the backbone of most real Compose stacks.
Redis + application
services:
cache:
image: redis:7-alpine
command: ["redis-server", "--save", "", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
networks: [backend]
app:
build: .
environment:
REDIS_URL: redis://cache:6379
depends_on:
cache: { condition: service_healthy }
networks: [backend]
networks: { backend: {} }
Prometheus + Grafana (observability)
services:
prometheus:
image: prom/prometheus:v2.54.1
configs:
- source: prom_conf
target: /etc/prometheus/prometheus.yml
volumes: ["promdata:/prometheus"]
ports: ["9090:9090"]
restart: unless-stopped
grafana:
image: grafana/grafana:11.2.0
environment:
GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin
secrets: [grafana_admin]
volumes: ["grafanadata:/var/lib/grafana"]
ports: ["3000:3000"]
depends_on: [prometheus]
restart: unless-stopped
configs:
prom_conf: { file: ./prometheus.yml }
secrets:
grafana_admin: { file: ./secrets/grafana_admin.txt }
volumes:
promdata:
grafanadata:
Backups and persistent data
Named volumes persist across down, but they are not backups. Back a volume up by running a throwaway container that tars it:
# Back up the pgdata volume to a tarball on the host
docker run --rm \
-v myproject_pgdata:/data:ro \
-v "$PWD":/backup \
alpine tar czf /backup/pgdata-$(date +%F).tar.gz -C /data .
For databases specifically, prefer a logical dump (pg_dump, mysqldump) over a raw volume copy — it’s portable across versions and restorable table-by-table.
Security hardening
- Publish only what must be external. Internal services use
expose:(or nothing) + the Compose network, neverports:. - Run as non-root. Set
user:or bake a non-rootUSERinto the image. - Use secrets, not env vars, for credentials.
- Drop capabilities and prevent privilege escalation:
services:
app:
security_opt: ["no-new-privileges:true"]
cap_drop: ["ALL"]
read_only: true # read-only root filesystem
tmpfs: ["/tmp"] # writable scratch where needed
Troubleshooting
docker-compose: command not found— you’re invoking the removed v1 binary. Usedocker compose(space). See compose command not found.- App can’t reach the database — it’s using
localhostinstead of the service name. Connect todb:5432, not127.0.0.1. - App crash-loops on startup — a
depends_onrace; add ahealthcheck+condition: service_healthy. env file ... not found— theenv_file:path is relative to the Compose file’s directory; check it exists. See env file not found.- Config rejected on
up— rundocker compose configto see the exact validation error; a common one is additional property is not allowed (a typo’d key or a directive at the wrong level).
Production checklist
- Pin image tags (or digests); never
latestin production. - Every stateful dependency has a
healthcheck+condition: service_healthy. - Credentials via
secrets:, notenvironment:. - Log rotation configured (
max-size/max-file) or logs shipped off-host. - Resource
limitsset so one service can’t starve the host. - Only externally-needed ports are published; databases have none.
no-new-privileges,cap_drop: [ALL], non-rootuser:where feasible.- A tested restore (not just backup) procedure for every named volume.
docker compose configvalidated in CI on every change.
Frequently asked questions
What’s the difference between docker compose and docker-compose?
docker compose (v2) is a plugin built into the Docker CLI and is the current, supported tool. docker-compose (v1, hyphenated) is the legacy standalone Python binary, now end-of-life. Use docker compose.
Do I still need the version: key at the top of compose.yaml?
No. The Compose Specification dropped it. A leading version: "3.8" is ignored (and noisy); remove it.
How do services find each other?
By service name over the shared Compose network via Docker’s internal DNS. Your app connects to db:5432, not localhost:5432.
Why does my app start before the database is ready?
depends_on waits only for the container to start, not for the service to accept connections. Add a healthcheck to the dependency and condition: service_healthy to depends_on.
How do I keep dev and prod settings in one place?
Use the override pattern: a production-safe compose.yaml plus a compose.override.yaml (dev, auto-merged) and a compose.prod.yaml you apply explicitly with -f in production.
How do I persist database data?
Mount a named volume at the database’s data directory (e.g. pgdata:/var/lib/postgresql/data) and declare it under volumes:. It survives docker compose down — but not down -v, and it is not a backup.
Related resources
- Free tool: Docker Compose Generator — build a valid stack visually with a live security scan.
- Free tool: Docker Production Readiness Auditor — 50-rule check of your Dockerfile/compose.
- Academy: the Docker Skills Academy — hands-on labs from images to production.
- Error library: Docker Compose error guides for specific failures.
Continue learning
Related Core Guides that build on this one.
- Linux CommandsA searchable Linux command reference for engineers — files, text, storage, processes, networking, services and troubleshooting, with Ubuntu-first examples.
- Cloud SecurityCloud security for DevOps — the shared responsibility model, IAM and least privilege, secrets, encryption, network segmentation, and supply-chain defense.
- DevOps PracticesThe practices that define modern delivery — IaC, CI/CD, GitOps, observability, SRE, progressive delivery — with when to use each, and when not to.
- DevOps ToolsA working engineer’s map of the DevOps toolchain — source control to platform engineering — with what each tool is for, its trade-offs, and how to choose.