Skip to main content
Docker · lesson 6 of 9
Docker intermediate Lesson 6 of 9

Also part of: Microservices

Docker Compose

Define and run multi-container applications with Docker Compose. Services, networks, volumes, and environment configuration.

By SkillByExample · Updated

What Is Docker Compose?

Docker Compose lets you define a multi-container application in a single docker-compose.yml file and manage it with one command. Instead of running multiple docker run commands with flags, you declare all services, their configuration, networks, and volumes in one place.

docker-compose.yml Structure

services:           # containers to run
  web:
    image: nginx
    ports:
      - "8080:80"

  api:
    build: ./api    # build from a local Dockerfile
    environment:
      - NODE_ENV=production
    depends_on:
      - db

  db:
    image: postgres:15
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: secret

volumes:            # named volumes
  pgdata:

networks:           # custom networks (optional — Compose creates a default)
  backend:

Core Commands

A tiny stack — a web server, a cache, and a worker — is enough to see what each command does.

# docker-compose.yml
services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8081:80"

  cache:
    image: redis:7-alpine

  worker:
    image: busybox:1.36
    command: ["sh", "-c", "while true; do echo waiting for work; sleep 30; done"]
docker compose up -d
docker compose ps
$ docker compose up -d
 Network core_default  Created
 Container core-cache-1  Started
 Container core-web-1  Started
 Container core-worker-1  Started
$ docker compose ps
NAME            IMAGE               SERVICE   STATUS         PORTS
core-cache-1    redis:7-alpine      cache     Up 8 seconds   6379/tcp
core-web-1      nginx:1.27-alpine   web       Up 8 seconds   0.0.0.0:8081->80/tcp
core-worker-1   busybox:1.36        worker    Up 8 seconds

docker compose up -d --build adds a rebuild step for any service with a build: key — skip it and Compose reuses an existing image. docker compose logs <service> tails one service’s output instead of interleaving all of them:

docker compose logs worker
$ docker compose logs worker
worker-1  | waiting for work

--scale runs more than one container for a service that has no fixed host port:

docker compose up -d --scale worker=3
docker compose ps
$ docker compose up -d --scale worker=3
 Container core-worker-2  Started
 Container core-worker-3  Started
$ docker compose ps
NAME            IMAGE               SERVICE   STATUS          PORTS
core-cache-1    redis:7-alpine      cache     Up 25 seconds   6379/tcp
core-web-1      nginx:1.27-alpine   web       Up 25 seconds   0.0.0.0:8081->80/tcp
core-worker-1   busybox:1.36        worker    Up 25 seconds
core-worker-2   busybox:1.36        worker    Up 6 seconds
core-worker-3   busybox:1.36        worker    Up 6 seconds

worker has no ports: mapping, so three replicas can coexist — web publishes 8081 on the host and can’t be scaled past 1 without a port clash. docker compose down -v reverses all of it, removing the containers, the network, and any named volumes:

docker compose down -v
$ docker compose down -v
 Container core-web-1  Removed
 Container core-worker-1  Removed
 Container core-worker-2  Removed
 Container core-worker-3  Removed
 Container core-cache-1  Removed
 Network core_default  Removed

Full Example: Node API + PostgreSQL + Redis

# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://postgres:secret@db:5432/myapp
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    volumes:
      - ./src:/app/src          # live reload in development

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:

Environment Variables

From a .env file (default)

Compose automatically loads a .env file in the same directory and substitutes ${VAR} references in docker-compose.yml:

# .env
DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
NODE_ENV=development
# docker-compose.yml
services:
  app:
    image: busybox:1.36
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - NODE_ENV=${NODE_ENV}
    command: ["sh", "-c", "echo DATABASE_URL=$$DATABASE_URL; echo NODE_ENV=$$NODE_ENV"]
docker compose run --rm app
$ docker compose run --rm app
DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
NODE_ENV=development

Multiple env files

--env-file swaps in a different file instead of .env, without touching docker-compose.yml:

docker compose --env-file .env.staging run --rm app
$ docker compose --env-file .env.staging run --rm app
DATABASE_URL=postgresql://postgres:secret@staging-db:5432/myapp
NODE_ENV=staging

Service Dependencies and Health Checks

# docker-compose.yml
services:
  api:
    image: busybox:1.36
    command: ["sh", "-c", "echo api started; sleep 60"]
    depends_on:
      db:
        condition: service_healthy   # wait until healthy, not just started

  db:
    image: postgres:15-alpine
    environment:
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 5s
docker compose up -d
$ docker compose up -d
 Network health_default  Created
 Container health-db-1  Created
 Container health-api-1  Created
 Container health-db-1  Started
 Container health-db-1  Waiting
 Container health-db-1  Healthy
 Container health-api-1  Started

db goes through Waiting while pg_isready keeps failing, then Healthy once Postgres accepts connections — only then does Compose start api. Without condition: service_healthy, depends_on would start api the moment db’s container exists, which can be seconds before Postgres is ready to accept a connection.

Running Commands

docker compose exec api node --version
$ docker compose exec api node --version
v18.20.8

exec runs inside a container that’s already up. run --rm instead starts a fresh, throwaway container for a one-off job, like a migration:

docker compose run --rm api npm run migrate
$ docker compose run --rm api npm run migrate

> migrate-demo@1.0.0 migrate
> echo 'Running migrations...' && echo 'Applied 001_create_users_table' && echo 'Migrations complete'

Running migrations...
Applied 001_create_users_table
Migrations complete

psql without -c opens an interactive shell, which has no captured output — pass a query to run it non-interactively:

docker compose exec db psql -U postgres -c "select version();"
$ docker compose exec db psql -U postgres -c "select version();"
                                         version
------------------------------------------------------------------------------------------
 PostgreSQL 15.19 on x86_64-pc-linux-musl, compiled by gcc (Alpine 15.2.0) 15.2.0, 64-bit
(1 row)

Overriding with Multiple Files

Three files, one service, each layer adding or replacing settings:

# docker-compose.yml — base config (shared)
services:
  web:
    image: nginx:1.27-alpine
    environment:
      - ENV_LABEL=base
# docker-compose.override.yml — dev overrides, merged in automatically
services:
  web:
    ports:
      - "8082:80"
    environment:
      - ENV_LABEL=development
# docker-compose.prod.yml — production overrides, opted into with -f
services:
  web:
    environment:
      - ENV_LABEL=production
    restart: unless-stopped
# Development — base + override merge automatically
docker compose up -d
docker compose exec web printenv ENV_LABEL
docker compose ps
$ docker compose up -d
 Network override_default  Created
 Container override-web-1  Started
$ docker compose exec web printenv ENV_LABEL
development
$ docker compose ps
NAME             IMAGE               SERVICE   STATUS         PORTS
override-web-1   nginx:1.27-alpine   web       Up 5 seconds   0.0.0.0:8082->80/tcp
# Production — base + prod.yml instead, named explicitly
docker compose down -v
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
docker compose exec web printenv ENV_LABEL
docker compose ps
$ docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
 Network override_default  Created
 Container override-web-1  Started
$ docker compose exec web printenv ENV_LABEL
production
$ docker compose ps
NAME             IMAGE               SERVICE   STATUS         PORTS
override-web-1   nginx:1.27-alpine   web       Up 6 seconds   80/tcp

docker-compose.override.yml only merges when no -f flags are given — naming docker-compose.prod.yml explicitly replaces it instead of adding to it, which is why the production container has no published port: 8082:80 lived only in the override file.

Practice

  1. In the “Core Commands” stack, scale worker to 3 and back down to 1. Confirm with docker compose ps that only one worker container is left.
Solution
docker compose up -d --scale worker=3
docker compose up -d --scale worker=1
docker compose ps
$ docker compose ps
NAME            IMAGE               SERVICE   STATUS          PORTS
core-cache-1    redis:7-alpine      cache     Up 10 seconds   6379/tcp
core-web-1      nginx:1.27-alpine   web       Up 10 seconds   0.0.0.0:8081->80/tcp
core-worker-1   busybox:1.36        worker    Up 10 seconds

up -d --scale worker=1 removes the extra replicas rather than erroring — Compose reconciles the running containers to match the number you asked for.

  1. Override NODE_ENV for a single run of the env-vars app service without editing .env or docker-compose.yml.
Solution
NODE_ENV=test docker compose run --rm app
$ NODE_ENV=test docker compose run --rm app
DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
NODE_ENV=test

A variable already set in the shell environment takes priority over the same name in .env — Compose only uses .env to fill in values that aren’t already set.

  1. Check whether db’s healthcheck in the “Service Dependencies” stack is currently passing, without re-reading the full docker compose ps table.
Solution
docker inspect --format '{{.State.Health.Status}}' health-db-1
$ docker inspect --format '{{.State.Health.Status}}' health-db-1
healthy

docker inspect reads a single container’s state directly — useful in a script where parsing docker compose ps output would be more fragile.

  1. List the npm scripts defined in the “Running Commands” api service’s package.json without running any of them.
Solution
docker compose run --rm api npm run
$ docker compose run --rm api npm run
Scripts available in migrate-demo@1.0.0 via `npm run-script`:
  migrate
    echo 'Running migrations...' && echo 'Applied 001_create_users_table' && echo 'Migrations complete'

npm run with no script name lists what’s available instead of running anything — handy for checking what a service’s image actually supports before calling it.

  1. Print the fully merged configuration Compose would use for the production deploy in “Overriding with Multiple Files”, without starting any containers.
Solution
docker compose -f docker-compose.yml -f docker-compose.prod.yml config
$ docker compose -f docker-compose.yml -f docker-compose.prod.yml config
name: override
services:
  web:
    environment:
      ENV_LABEL: production
    image: nginx:1.27-alpine
    networks:
      default: null
    restart: unless-stopped
networks:
  default:
    name: override_default

config resolves every -f file, substitutes ${VAR} references, and prints the result — the same merge Compose uses internally, without the side effect of starting anything.

Frequently Asked Questions

What version of Docker Compose should I use?
Use the Compose V2 plugin (docker compose, not docker-compose). It ships with Docker Desktop and Docker Engine 23+. The standalone docker-compose binary is deprecated.
Do I need to define networks in docker-compose.yml?
No. Compose creates a default network automatically and all services join it. Define custom networks only when you need isolation between groups of services.
How do I run a one-off command against a service?
Use docker compose run --rm service-name command. This starts a fresh container for the service and removes it on exit.