Docker Compose

Defining a multi-container app in docker-compose.yml, environment variables, and .env files.

Why Compose exists

Running a realistic app by hand means juggling several long docker run commands — one for the app, one for the database, one for a cache — each with its own flags for networks, volumes, ports, and environment variables, run in the right order, every single time. Docker Compose replaces all of that with a single declarative YAML file describing every service and how it fits together, plus two commands to bring the whole stack up or down.

A complete multi-container example

Here's a realistic docker-compose.yml for a Node.js app backed by PostgreSQL and Redis:

YAML
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://appuser:${DB_PASSWORD}@db:5432/appdb
      - REDIS_URL=redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=appuser
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_DB=appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  cache:
    image: redis:7-alpine
    volumes:
      - redisdata:/data
    restart: unless-stopped

volumes:
  pgdata:
  redisdata:

A few things worth calling out:

  • build: . on the app service tells Compose to build an image from the Dockerfile in the current directory, rather than pulling a pre-built image like db and cache do.
  • Service names double as hostnames. Compose automatically creates a network for the whole file and gives each service DNS resolution by its service name — that's exactly why DATABASE_URL can just say db and REDIS_URL can just say cache, with no manual network setup needed (this is the same mechanism covered in Volumes and Networking, just wired up for you automatically).
  • depends_on with condition: service_healthy makes the app service wait until the database actually reports healthy (via its healthcheck), not just "started" — a plain depends_on: [db] only waits for the container to start, which is often too early for a database that's still initializing.
  • Named top-level volumes: declares pgdata and redisdata as Docker-managed named volumes, persisting data across docker compose down and back up again (as long as you don't pass -v, covered below).
  • ${DB_PASSWORD} references an environment variable — covered next.

Environment variables and .env files

Hardcoding secrets like POSTGRES_PASSWORD directly into docker-compose.yml is a bad habit — that file is typically committed to version control. Compose automatically reads a .env file sitting next to docker-compose.yml and substitutes ${VARIABLE} references with its values:

Bash
# .env  (add this file to .gitignore — never commit real secrets)
DB_PASSWORD=s3cr3t-local-only
Bash
$ docker compose config    # renders the final config with variables substituted, without starting anything

For values an individual service's application code needs directly (not just Compose's own substitution), the environment: key under that service — as used for NODE_ENV and the constructed DATABASE_URL above — is what actually injects them into the container's process environment.

Bringing the stack up and down

Bash
# Build images (if needed) and start every service, in the background
$ docker compose up -d
[+] Running 4/4
 ✔ Network myapp_default   Created
 ✔ Container myapp-db-1    Healthy
 ✔ Container myapp-cache-1 Started
 ✔ Container myapp-app-1   Started

# See the status of every service defined in the file
$ docker compose ps
NAME              IMAGE           STATUS
myapp-app-1       myapp-app       Up 10 seconds
myapp-db-1        postgres:16-alpine   Up 15 seconds (healthy)
myapp-cache-1     redis:7-alpine  Up 15 seconds

# Follow logs across all services, or just one
$ docker compose logs -f
$ docker compose logs -f app

# Rebuild the app image after a Dockerfile or source change, then restart it
$ docker compose up -d --build app

# Stop and remove containers and the network (volumes are kept by default)
$ docker compose down

# Stop and remove containers, network, AND named volumes — deletes persisted data
$ docker compose down -v

docker compose down (no -v) is the safe default — your database's actual data survives a down/up cycle, since it lives in the pgdata named volume, not in the container itself. Only reach for -v when you genuinely want to wipe persisted data, such as resetting a local dev database to a clean state.

Common mistakes

  • Committing a .env file with real secrets to version control — it should be listed in .gitignore, with only a .env.example (showing which variables are needed, without real values) committed instead.
  • Using a plain depends_on: [db] for a service that needs the database to be truly ready, not just started — the container can be "up" while Postgres is still initializing internally, causing intermittent connection failures on the first docker compose up.
  • Running docker compose down -v out of habit and being surprised that local development data (like seeded test users) is gone — -v explicitly deletes named volumes along with the containers.
  • Forgetting that changing a Dockerfile requires --build (or a separate docker compose build) — docker compose up alone won't rebuild an image just because its Dockerfile changed.

Interview questions

Q: What problem does Docker Compose solve that plain docker run doesn't? It replaces a set of manually-run, easy-to-get-wrong docker run commands (with their networking, volume, and environment flags) with one declarative YAML file describing the whole multi-container application, brought up or down with a single command. It also automatically creates a shared network with name-based DNS resolution between services, which otherwise has to be set up manually.

Q: Why might depends_on alone not be enough to guarantee a database is ready before the app starts? Plain depends_on only waits for the dependency's container to start, not for the application inside it (like Postgres) to finish initializing and actually be ready to accept connections. Adding a healthcheck to the database service and using depends_on: { db: { condition: service_healthy } } makes Compose wait for the database to report itself healthy first.

Q: What's the difference between docker compose down and docker compose down -v? Both stop and remove the containers and the network Compose created. docker compose down -v additionally removes any named volumes declared in the file, permanently deleting persisted data (like a database's contents) — without -v, that data survives and is reattached the next time you run docker compose up.