Docker Compose

Difficulty

The problem: real applications are rarely just one container

# Manually orchestrating a 3-service application with plain docker commands:
docker network create my-app-network
docker volume create db-data
docker run -d --network my-app-network --name db -v db-data:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret postgres:16
docker run -d --network my-app-network --name api -e DB_HOST=db \
  -p 3000:3000 myapi:1.0
docker run -d --network my-app-network --name web -p 8080:80 myweb:1.0

Even a modest three-service application requires remembering and correctly sequencing several separate commands, each with several flags. This is error-prone to type out manually every time, hard to share consistently with teammates, and impossible to meaningfully version-control as a single coherent unit.

The Compose solution: one declarative file

# compose.yaml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - db-data:/var/lib/postgresql/data

  api:
    image: myapi:1.0
    environment:
      DB_HOST: db
    ports:
      - "3000:3000"
    depends_on:
      - db

  web:
    image: myweb:1.0
    ports:
      - "8080:80"

volumes:
  db-data:
docker compose up -d      # brings up ALL three services, the network, and the volume
docker compose down        # tears it all down again

One file captures the entire application's shape: every service, its configuration, and how they relate. Two simple commands then bring the whole thing up or down consistently, every time, on any machine with Docker and Compose installed.

What Compose handles automatically that you'd otherwise do manually

  • Creates a dedicated network automatically — every service defined in the file can reach every other service by its service name (db, api, web) via DNS, with zero manual network setup (see the networking topic's DNS question). Compose creates a user-defined bridge network for the whole project by default.
  • Manages startup/shutdown ordering — respects depends_on relationships (with the caveats covered in that question).
  • Consistent, reproducible environment — the exact same file, committed to version control, produces the exact same multi-container setup for every developer on a team, or in CI. No one needs to remember or manually re-type the individual docker run commands.

What Compose is (and isn't) meant for

Compose is designed and best suited for single-host multi-container applications — local development environments, simple single-server deployments, CI test environments spinning up a full application stack for integration testing. It is explicitly not a multi-host orchestrator. It has no concept of scheduling containers across many machines, no built-in self-healing or rescheduling on node failure, and no rolling-update mechanism comparable to Kubernetes's. (See that stack's Compose-vs-Kubernetes comparison question for exactly where Compose's scope ends and a real orchestrator's begins.)

Why this matters for local development specifically

Compose has become the standard way to "spin up this whole application's dependencies locally." A single docker compose up command that brings up a database, a cache, a message queue, and the application itself, all correctly networked together, is a dramatically better onboarding and local-development experience. This beats expecting every developer to manually install and configure each dependency directly on their own machine.

Related Resources

services — the core of every Compose file

services:
  api:
    image: myapi:1.0             # OR: build: ./api  (build from a local Dockerfile instead)
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
    volumes:
      - api-logs:/app/logs
    networks:
      - backend
    depends_on:
      - db
    restart: unless-stopped

Each entry under services corresponds to (roughly) one docker run invocation's worth of configuration — image/build source, ports, environment, volumes, network attachments, startup dependencies, and restart policy, all in one place, per service.

build vs. image — where the container's code comes from

services:
  api:
    build:
      context: ./api
      dockerfile: Dockerfile.prod
    image: myapi:1.0    # optional: tag the resulting build with this name too

image alone pulls a pre-built image from a registry. build instead builds the image locally from a Dockerfile. The two are commonly used together during local development — building from source, then tagging it with a name. A production deployment, on the other hand, more often just references a pre-built image that CI already built and pushed.

networks (top-level) — custom networks beyond the default

services:
  web:
    networks:
      - frontend
  api:
    networks:
      - frontend
      - backend
  db:
    networks:
      - backend

networks:
  frontend:
  backend:

By default, Compose creates a single network shared by every service in the project. This top-level networks section, paired with a networks: list under each service, is only needed when you want the same kind of deliberate multi-network segmentation covered in the plain-Docker networking topic — for example, keeping db unreachable from web directly, only reachable via api.

volumes (top-level) — declaring named volumes

services:
  db:
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:
    driver: local     # optional -- and can specify driver-specific options here too

A named volume referenced by a service (db-data above) should generally also be declared under the top-level volumes: section. This is where you would specify a non-default driver or driver options, if needed. A bare declaration — just db-data: with nothing else — is sufficient for the common case of a plain, Docker-managed local volume.

Environment variables and .env files

services:
  api:
    environment:
      - DB_PASSWORD=${DB_PASSWORD}    # substituted from a .env file or the shell environment
    env_file:
      - .env.api                       # load an entire file of variables at once

Covered in full in the dedicated environment-variables question — Compose supports both inline environment: entries (with variable substitution from a .env file) and loading an entire separate file via env_file:.

Why most real Compose files are dominated by the services section

For the common case of a single-network application with standard, Docker-managed volumes, the networks and volumes top-level sections often need little or no extra configuration beyond a bare declaration. The real substance of a Compose file — and what most of your time authoring one goes into — is correctly configuring each service's image or build, environment, ports, volumes, and dependencies within the services section.

Related Resources

Two distinct mechanisms that are easy to conflate

1. The .env file — substituted into the compose file itself, at parse time

# .env (same directory as compose.yaml)
DB_PASSWORD=supersecret
API_PORT=3000
# compose.yaml
services:
  api:
    ports:
      - "${API_PORT}:3000"
    environment:
      - DB_PASSWORD=${DB_PASSWORD}

Compose automatically reads a file literally named .env in the project directory. It uses its values to fill in any ${VAR_NAME} placeholders within the compose file's own YAML text, before the file is even processed. This happens regardless of whether those placeholders end up inside an environment: block, a ports: mapping, an image tag, or anywhere else in the file.

2. environment: / env_file: — set inside the running container

services:
  api:
    environment:
      - NODE_ENV=production          # set directly, a literal value
      - DB_PASSWORD=${DB_PASSWORD}     # set via .env substitution (mechanism #1, feeding into #2)
    env_file:
      - .env.api                        # load an ENTIRE separate file's variables into the container

environment: and env_file: control what environment variables the running container itself actually sees, equivalent to docker run -e. This is a runtime concern for the application inside the container, distinct from the .env file's job of substituting values into the compose YAML's own text at parse time.

Why the distinction matters in practice

services:
  api:
    image: myapi:${APP_VERSION}     # .env substitution determines WHICH IMAGE TAG to use
    environment:
      - APP_VERSION=${APP_VERSION}   # separately, ALSO expose that same value to the running app

A value from .env can be used purely to parameterize the compose file itself, like choosing an image tag or a host port, without necessarily also being passed into the container's environment. Conversely, a container's environment: block can set variables that have nothing to do with any .env substitution at all, using plain literal values. Confusing "this variable is available for compose-file templating" with "this variable is available inside my running application" is a common source of "why isn't my app seeing this environment variable" confusion.

A dedicated env_file, separate from .env

# .env.api
NODE_ENV=production
LOG_LEVEL=info
DB_CONNECTION_STRING=postgres://...
services:
  api:
    env_file:
      - .env.api

env_file: is specifically for loading a whole file's worth of variables directly into one service's container. This is distinct from the special, automatically-loaded .env file, which is scoped to the whole compose file's variable substitution, not tied to any one particular service.

Never commit real secrets into either mechanism, uncommented

# .gitignore
.env
.env.*

Both .env and any custom env_file are plain, unencrypted text. Genuinely sensitive values, such as database passwords or API keys, should not be committed to version control in either form. The standard practice is to .gitignore them and provide a .env.example template with placeholder, non-sensitive values showing the expected shape. This mirrors the same secrets-hygiene principle covered in the SQL/Databases and Kubernetes stacks.

Foreground mode — attached, blocking

docker compose up
# api-1  | Server listening on port 3000
# db-1   | database system is ready to accept connections
# web-1  | nginx started
# ... (combined, live, color-coded log output from every service, streamed to your terminal)

Your terminal is now attached to the combined output of every running service. This is genuinely useful when actively developing and you want to watch logs from all services simultaneously in one place, with each line prefixed by which service produced it. Until you press Ctrl+C, this terminal is blocked entirely — you cannot run other commands in that same session while docker compose up is running, unless you open a separate terminal. Pressing Ctrl+C sends a stop signal to every service and then returns control of your terminal.

Detached mode — backgrounded, returns immediately

docker compose up -d
# [+] Running 3/3
#  ✔ Container myapp-db-1   Started
#  ✔ Container myapp-api-1  Started
#  ✔ Container myapp-web-1  Started

Starts every service in the background and immediately hands your terminal back — you can continue running other commands in the same session. To view logs afterward, you explicitly ask for them:

docker compose logs -f       # follow combined logs from all services, on demand
docker compose logs -f api    # follow logs from just the "api" service

Why -d is the standard choice for most real usage

For anything beyond a brief moment of active, hands-on local development where you specifically want to watch logs stream by, -d is almost always what you want. It lets your terminal remain free for other work, and it matches how you would run things in CI, staging, or any semi-automated context. You are not going to leave a terminal session permanently attached and blocked in a CI pipeline or on a server. Combined with docker compose logs -f whenever you do actually want to look at output, detached mode gives you the same log visibility on demand, without the terminal-blocking downside of foreground mode.

Bringing things back down

docker compose down          # stops and REMOVES containers, default network (but NOT named volumes)
docker compose down -v        # also removes named volumes -- be careful, this deletes data!
docker compose stop            # just stops containers, without removing them at all

Regardless of whether you started with foreground or detached mode, docker compose down is the standard teardown command. It is worth explicitly noting that plain docker compose down does not delete named volumes by default, so your data survives. docker compose down -v does delete them, and this distinction matters enormously in practice. An accidental -v on a production-adjacent environment can mean genuine, irreversible data loss.

The basic (and incomplete) version

services:
  db:
    image: postgres:16
  api:
    image: myapi:1.0
    depends_on:
      - db

docker compose up will start db's container before starting api's container. But critically, this only guarantees that db's container process has started — not that PostgreSQL inside it has actually finished its own initialization and is ready to accept connections. Database engines (and many other services) often take a real, non-trivial amount of time after their process starts before they're genuinely ready to serve requests.

The resulting race condition

1. docker compose up starts db's container
2. Compose immediately proceeds to start api's container (db's PROCESS has started)
3. api's application code tries to connect to the database...
4. ...but Postgres inside db is STILL initializing, not yet accepting connections
5. api's connection attempt fails, possibly crashing api on startup

This is a very common, easy-to-hit real-world Compose bug. Everything looks correctly ordered, since depends_on: [db] is right there in the file. But the actual application-level readiness gap between "container started" and "service ready" isn't accounted for at all by plain depends_on.

The fix: condition: service_healthy

services:
  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
  api:
    image: myapi:1.0
    depends_on:
      db:
        condition: service_healthy

This changes the guarantee meaningfully: api's container now won't even be started until db's own HEALTHCHECK (see the lifecycle topic's question) actually reports healthy — genuinely waiting for readiness, not just process start. This requires the dependency (db) to actually define a HEALTHCHECK in the first place; condition: service_healthy has nothing to check against otherwise.

Other available conditions

depends_on:
  db:
    condition: service_started      # the default -- just waits for the container to start
  migrations:
    condition: service_completed_successfully   # waits for a one-off task container to EXIT successfully

service_completed_successfully is specifically useful for a one-off setup task — for example, running database migrations as a separate service that's expected to run once and exit. A dependent service can wait for this task to fully finish, rather than waiting for a long-running service to become healthy.

What depends_on still doesn't solve, even with service_healthy

Even with a proper healthcheck-gated dependency, Compose's depends_on only governs the initial startup sequence. It says nothing about what happens if db later crashes and restarts while api is already running. api's application code still needs its own resilience — retry logic, connection pooling with reconnection — to handle a dependency becoming temporarily unavailable after both services are already up and running. depends_on is purely a startup-ordering tool, not an ongoing dependency-health guarantee for the whole lifetime of the application.