Skip to content
DevOps AI ToolKit
Newsletter
Core Guide · Delivery & Automation

Docker Compose

Docker Compose from first service to production — the architecture, every core directive, complete runnable stacks (NGINX, Postgres, Redis, Prometheus + Grafana), and the hardening that separates a demo compose.yaml from one you'd run in production.

Last reviewed August 2026 Guide · Production stacks · 30 min read

Technically validated: Examples use the Compose Specification (compose.yaml) and the Docker Compose v2 CLI (docker compose ...), validated against Docker Engine 27 / Compose v2.29.

On this page

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

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, never ports:.
  • Run as non-root. Set user: or bake a non-root USER into 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. Use docker compose (space). See compose command not found.
  • App can’t reach the database — it’s using localhost instead of the service name. Connect to db:5432, not 127.0.0.1.
  • App crash-loops on startup — a depends_on race; add a healthcheck + condition: service_healthy.
  • env file ... not found — the env_file: path is relative to the Compose file’s directory; check it exists. See env file not found.
  • Config rejected on up — run docker compose config to 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 latest in production.
  • Every stateful dependency has a healthcheck + condition: service_healthy.
  • Credentials via secrets:, not environment:.
  • Log rotation configured (max-size/max-file) or logs shipped off-host.
  • Resource limits set so one service can’t starve the host.
  • Only externally-needed ports are published; databases have none.
  • no-new-privileges, cap_drop: [ALL], non-root user: where feasible.
  • A tested restore (not just backup) procedure for every named volume.
  • docker compose config validated 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.

Continue learning

Related Core Guides that build on this one.

Written by James Joyner IV, Sr. Systems Software Engineer — for engineers who run what they build.

Last reviewed August 2026. Found an error or an out-of-date command? Tell us — accuracy is the point of a Core Guide.