Steven's Knowledge

Docker Compose

Define multi-container applications with one YAML file - services, networks, volumes, healthchecks, profiles

Docker Compose

Compose lets you describe a multi-container app — say, a web service, a Postgres, and a Redis — in one YAML file, then docker compose up to bring the whole thing online.

It's the right tool for local development, CI test environments, and small single-host production deployments. For multi-host production, move to Kubernetes.

A Full-Stack Example

# docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: runner
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/myapp
      REDIS_URL:    redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER:     user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB:       myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes --maxmemory 256mb
    volumes:
      - redis-data:/data

volumes:
  pgdata:
  redis-data:

Bring it up:

docker compose up -d                       # detached, build if needed
docker compose ps                          # see what's running
docker compose logs -f app                 # follow app's logs
docker compose exec app /bin/sh            # shell into the app
docker compose down                        # stop and remove containers
docker compose down -v                     # also remove named volumes (destructive!)

docker compose down -v deletes named volumes — the Postgres data goes with it. Reserve it for "wipe my dev env clean."

Service Discovery

Containers in the same Compose project share a network. Each service is reachable by its service name:

services:
  app:
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/myapp   # 'db' resolves to the db service
      REDIS_URL:    redis://redis:6379                   # 'redis' resolves to the redis service

No need to publish ports between services — only publish ports that humans (or the load balancer) need to reach.

depends_on and Healthchecks

depends_on controls start order, not "ready order." A service marked as a dependency just means "start me after this one." For "wait until it's actually accepting connections," combine with healthchecks:

app:
  depends_on:
    db:
      condition: service_healthy             # waits for healthcheck to pass

db:
  image: postgres:16-alpine
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
    interval: 5s
    retries: 10

Without service_healthy, your app starts before Postgres has accepted connections — and crashes on the first connect.

Networks

By default Compose creates one network for the project, joining every service to it. Need finer control? Define networks explicitly:

services:
  nginx:
    image: nginx
    networks: [frontend]

  app:
    build: .
    networks: [frontend, backend]

  db:
    image: postgres:16-alpine
    networks: [backend]

networks:
  frontend:
    driver: bridge

  backend:
    driver: bridge
    internal: true                           # no external connectivity at all

nginx and app talk on frontend; app and db talk on backend. db has no path to the outside world.

DriverUse case
bridgeDefault — isolated, one host
hostContainer shares host network (no isolation, no port mapping needed)
noneNo networking at all
overlayMulti-host (Docker Swarm only)

Volumes

Three flavours, all useful:

services:
  app:
    volumes:
      # Named volume — Docker-managed
      - app-data:/app/data

      # Bind mount — host directory; great for dev (live edits)
      - ./src:/app/src:ro                    # ro = read-only

      # tmpfs — in-memory, ephemeral
      - type: tmpfs
        target: /app/tmp
        tmpfs:
          size: 100m

volumes:
  app-data:
    driver: local                            # default

Rules of thumb:

UseWhen
Named volumePersistent app data (DBs, queues, file storage)
Bind mountSource code in dev; config files the host owns
tmpfsScratch space; secrets you don't want on disk
:ro everywhere possibleOne less mistake to make

Environment

Three ways to inject environment variables:

services:
  app:
    # 1. Inline
    environment:
      NODE_ENV: production
      LOG_LEVEL: info

    # 2. From an .env-style file
    env_file:
      - .env
      - .env.local

    # 3. Substitution from your shell into the YAML
    environment:
      DATABASE_URL: "postgres://user:${DB_PASSWORD}@db/myapp"

Variables in ${...} come from your shell or a .env file Compose auto-loads from the project directory. Don't commit .env if it contains secrets — gitignore it.

Profiles

For optional services (only-in-CI, only-with-monitoring):

services:
  app:
    build: .

  db:
    image: postgres:16-alpine

  # Only starts when explicitly requested
  prometheus:
    image: prom/prometheus
    profiles: [monitoring]

  grafana:
    image: grafana/grafana
    profiles: [monitoring]
docker compose up -d                             # app + db
docker compose --profile monitoring up -d        # app + db + prometheus + grafana

Compose Overrides

Compose merges multiple files left-to-right. The convention:

FileRole
docker-compose.ymlThe base — committed, shared
docker-compose.override.ymlAuto-loaded; for local dev tweaks
docker-compose.prod.ymlProduction overrides, applied explicitly
# docker-compose.override.yml — local dev
services:
  app:
    build:
      target: development                    # different build stage in dev
    volumes:
      - .:/app                               # live source mount
    environment:
      LOG_LEVEL: debug
    ports:
      - "9229:9229"                          # node inspector
docker compose up -d                                              # base + override
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Resource Limits

Don't run dev or CI containers unlimited:

services:
  app:
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
        reservations:
          cpus: "0.25"
          memory: 128M

Limits prevent one bad service from starving the others on a shared dev host.

Useful Commands

# Bring up just one service (and its deps)
docker compose up -d app

# Force a rebuild
docker compose build --no-cache

# See effective config after merging overrides
docker compose config

# Run a one-off command in the app's environment
docker compose run --rm app npm test

# Restart just one service
docker compose restart app

# Scale a service to N replicas (single host)
docker compose up -d --scale app=3

Compose for Single-Host Production

It's not Kubernetes, but for small services it's fine. A few habits:

  1. Pin every image tag. No latest.
  2. restart: unless-stopped on everything you care about.
  3. Healthchecks on everything, so restart policies actually work.
  4. Named volumes for state, bind mounts only for read-only config.
  5. Resource limits on every service.
  6. Reverse proxy (nginx, Caddy, Traefik) on the public side; everything else internal: true.
  7. Back up named volumes: docker run --rm -v pgdata:/data -v "$PWD":/backup alpine tar czf /backup/pg-$(date +%F).tgz -C / data.
  8. Upgrade with care: docker compose pull && docker compose up -d (rolling for HA = Kubernetes; for one host, accept brief downtime or stand up the new stack in parallel and switch DNS).

What's Next

You can run multi-container apps end-to-end. Last piece: harden it for production — security, registries, image hygiene, ops habits → Best Practices.

On this page