Skip to main content
CodeOath
← All posts

Docker105 min total · 19 parts

Docker Fundamentals: Images, Containers, and Writing a Good Dockerfile

Part 13 of 19 · ~2 min

Docker Compose: Running More Than One Container Together

By now snapledger is four containers — web, worker, redis, postgres — and starting them by hand, in the right order, on the right network, with the right volumes, has become a five-minute ritual nobody enjoys. docker-compose.yml declares the whole thing in one file:

services:
  web:
    build: ./web
    ports: ["3000:3000"]
    environment:
      - DATABASE_URL=postgres://postgres:5432/snapledger
      - REDIS_URL=redis://redis:6379
    depends_on:
      postgres:
        condition: service_healthy
  worker:
    build: ./worker
    environment:
      - DATABASE_URL=postgres://postgres:5432/snapledger
      - REDIS_URL=redis://redis:6379
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
  redis:
    image: redis:7
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
  postgres:
    image: postgres:16
    environment:
      - POSTGRES_PASSWORD=devpassword
      - POSTGRES_DB=snapledger
    volumes: ["pgdata:/var/lib/postgresql/data"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5
volumes:
  pgdata:

One docker compose up builds all four images that need building, starts all four containers, and — without a single docker network create anywhere in the file — puts them on a network of their own, which is why web's DATABASE_URL above gets to say postgres as a plain hostname and have it resolve. Compose is quietly doing the exact thing the networking chapter made you do by hand for worker and redis; here, it's just implicit in the file rather than a command you had to remember to run. Tearing it down is docker compose down, and it leaves pgdata alone unless you specifically add -v — given how close that volume came to giving you a heart attack two chapters ago, that's one flag worth reading twice before you type it.

A new engineer — call it a hypothetical fifth hire down the line, or just Priya on a machine she wiped and reset — runs docker compose up for the very first time and watches this scroll past:

worker-1    | Error: connect ECONNREFUSED 172.19.0.3:5432
worker-1    | retrying in 2000ms...
worker-1 exited with code 1
worker-1    | Error: connect ECONNREFUSED 172.19.0.3:5432
worker-1    | retrying in 2000ms...
postgres-1  | database system is ready to accept connections
worker-1    | connected — starting queue loop

Three failed connection attempts, a scary stack trace scrolling by each time, and then it just settles down and works normally, forever, on every run after the first. Nothing was actually broken. What depends_on promises and what it delivers are two different things: it guarantees Compose launches postgres's container before worker's, and nothing beyond that — a launched container and a database that's finished initializing and is actually taking connections are not the same moment at all. The thing that closes that gap is the pairing already sitting in the file above, condition: service_healthy reading off the healthcheck block — that's what makes worker wait for Postgres to genuinely be able to answer a query, not just for its process to exist. Wire it that way from the start, as the file above already does, and the three failed attempts never happen in the first place.