A Docker Compose Baseline for Local Development

A new engineer should be able to clone the repository, run one command, and have a working stack. If onboarding still needs a page of setup steps, the setup steps are the bug.

The file that gets you there is a single compose.yaml that wires the application to the services it depends on, with sensible defaults baked in. Most of the ones I see in the wild are almost right and quietly broken in the same place: they list a database under depends_on, assume that means “wait for the database”, and then flake on the first run of the day when the app races the database to readiness. Here is a baseline that does not do that, plus the two features that keep it from turning into a per-developer mess.

Why depends_on alone does not wait

The short form of depends_on only controls start order. Compose starts the database container before the app container, but “started” means the container process is running, not that PostgreSQL is accepting connections. On a cold start the database spends several seconds initialising, and your app connects, fails, and either crashes or enters a retry loop. It usually works on the second attempt, which is exactly why it survives in codebases for years.

Free · 4 minutes

Is your engineering team shipping safely, or quietly accumulating risk?

Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.

Compose v2 fixes this with the long form of depends_on combined with a healthcheck on the dependency. You define what “ready” means for each service, and you gate the app on condition: service_healthy. Compose then holds the app back until the healthcheck actually passes. No application-level wait scripts, no wait-for-it.sh, no sleep-and-hope.

The compose.yaml

This is the whole file. It defines the app with a bind mount for hot reload, Postgres 17 and Redis 7 on named volumes so data survives a restart, healthchecks on both backing services, and a dedicated network. Note that there is no top-level version: key: it is obsolete in Compose v2 and only earns you a warning.

# compose.yaml
name: myapp

services:
  app:
    build: .
    ports:
      - "8000:8000"
    volumes:
      - ./src:/app/src          # bind mount for hot reload
    env_file:
      - .env
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - backend

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s
    networks:
      - backend

  redis:
    image: redis:7-alpine
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - backend

volumes:
  db-data:
  redis-data:

networks:
  backend:

A few things worth being deliberate about. The healthcheck test for Postgres uses pg_isready against the same user and database the app will use, so a pass genuinely means the app can connect. The start_period gives the first boot ten seconds to run its one-time initialisation before failing healthchecks start counting against the retry budget. Redis answers ping with PONG once it is up, which is all the readiness signal it needs. The app reaches its dependencies by service name — db and redis resolve over the backend network’s DNS — so nothing is hard-coded to an IP.

The matching .env sits next to the compose file and is git-ignored. Keep it boring and local-only:

# .env
POSTGRES_USER=app
POSTGRES_PASSWORD=change-me-locally
POSTGRES_DB=app_dev

DATABASE_URL=postgresql://app:change-me-locally@db:5432/app_dev
REDIS_URL=redis://redis:6379/0

Commit an .env.example with the keys and dummy values so a new clone knows what to fill in, and keep the real .env out of the repository. With that in place, docker compose up brings up Postgres and Redis, waits for both to report healthy, and only then starts the app.

Optional services with profiles

Not everyone needs every service every time. A mail catcher, an admin UI, a metrics stack — useful occasionally, wasteful to start on every up. Profiles let a service opt out of the default set and only start when asked. Add a profiles key to the service:

  mailhog:
    image: mailhog/mailhog:latest
    ports:
      - "8025:8025"
    profiles:
      - dev-tools
    networks:
      - backend

A plain docker compose up now ignores mailhog entirely. When you want it, run docker compose --profile dev-tools up, or set COMPOSE_PROFILES=dev-tools in your environment. Services without a profiles key always start, so your core stack is unaffected.

Per-developer tweaks without editing the shared file

Someone will want a debugger port, a different bind mount, or an extra environment variable, and none of that belongs in the shared file. Compose reads compose.override.yaml automatically if it exists and merges it on top of the base file. Git-ignore the override and each developer keeps their own:

# compose.override.yaml (git-ignored, per developer)
services:
  app:
    environment:
      DEBUG: "true"
    ports:
      - "5678:5678"        # debugpy

Scalar values in the override replace the base; lists such as ports are additive, so the debugger port joins the ones already declared. The shared compose.yaml stays canonical and reviewable, and personal preferences never land in a pull request. That separation is the same discipline that makes a pre-commit baseline work: shared config in the repository, local escape hatches kept local.

Why this is worth getting right

A local stack that comes up cleanly on the first command is not a nicety. It is the thing that lets a new hire ship on day one instead of day three, and it is the same Redis-backed local environment you will lean on when you test something like idempotent request handling before it reaches production. Reliable local setup also quietly feeds your delivery metrics: lead time starts at the first commit, and the first commit starts with an environment that works.

Put the healthcheck and the service_healthy gate in from the start. The five lines they cost are cheaper than the intermittent “works on the second try” bug they prevent — a bug that never quite reproduces on demand and never quite gets fixed.

Build and rescue work

Hands-on delivery of this kind is handled by Sixteen Pillars Studio.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.