Home
DevOps & Cloud Engineering / Lesson 12 — Docker Compose for Local Development

Docker Compose for Local Development

Define your whole stack in one file. The fastest way to onboard new engineers.


What Compose Solves

A modern app rarely runs alone. Your local development stack might include:
• Your app (Node, Python, Go, etc.)
• PostgreSQL
• Redis
• Localstack or minio for S3 emulation
• A message queue (RabbitMQ, Kafka)
• Maybe a search engine (Elasticsearch)

Setting up all this manually is painful. Engineers spend their first week installing services. Versions drift. "It works on my machine" reappears.

Docker Compose solves this. One YAML file describes the whole stack. One command (docker compose up) starts everything. Onboarding goes from days to minutes.

Compose is for development. For production, use Kubernetes or your cloud's container service. But for local dev, dev environments, simple integration tests, and small self-hosted stacks, Compose is unmatched.


A Simple Compose File

Save as compose.yml (or docker-compose.yml — both work):

YAML
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:secret@db:5432/myapp
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    volumes:
      - ./src:/app/src       # hot reload during dev
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"          # expose to host for psql access
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    ports:
      - "6379:6379"

volumes:
  pgdata:

Run:

Bash
docker compose up           # foreground, see all logs
docker compose up -d        # detached
docker compose down         # stop and remove
docker compose down -v      # also remove volumes (clean slate)
docker compose logs -f app  # tail logs from one service
docker compose exec app sh  # shell into running app
docker compose ps           # see status
docker compose restart app  # restart one service

Networking is automatic — app reaches db at hostname db. Compose creates a private network and adds a DNS entry for each service.


Common Patterns

Override files for different environments:

YAML
# compose.override.yml — local dev overrides (loaded automatically)
services:
  app:
    environment:
      LOG_LEVEL: debug
    volumes:
      - ./src:/app/src

# compose.test.yml — testing overrides
services:
  app:
    environment:
      NODE_ENV: test

Combine:

Bash
docker compose -f compose.yml -f compose.test.yml up

Profiles — services that aren't always needed:

YAML
services:
  app: {...}
  db: {...}

  storybook:
    image: my-storybook
    profiles: [tools]      # only starts when explicitly requested

  mailhog:
    image: mailhog/mailhog
    profiles: [tools]
Bash
docker compose up                     # only app and db
docker compose --profile tools up     # everything

Healthchecks ensure dependencies are READY, not just started:

YAML
db:
  image: postgres:16
  healthcheck:
    test: ["CMD-SHELL", "pg_isready"]
    interval: 5s

app:
  depends_on:
    db:
      condition: service_healthy   # waits for healthcheck to pass

Without service_healthy, your app starts as soon as Postgres's PROCESS starts — but Postgres might still be initializing.

Building from source:

YAML
app:
  build:
    context: .
    dockerfile: Dockerfile.dev
    args:
      NODE_VERSION: '20'

Mix of build and pull — useful for monorepos:

YAML
services:
  api:
    build: ./api
  web:
    build: ./web
  db:
    image: postgres:16

Hot Reload for Fast Development

The best dev loop is editing code on your host and seeing the change reflected immediately in the running container.

Volume mount your source:

YAML
services:
  app:
    build: .
    command: npm run dev      # use a watch-mode command
    volumes:
      - ./src:/app/src
      - /app/node_modules     # exclude — don't overlay host's empty dir
    environment:
      CHOKIDAR_USEPOLLING: 'true'  # Linux file events sometimes need polling

Common gotcha — that /app/node_modules line. The host bind mount of ./src:/app/src would normally hide the container's /app/node_modules if you also mounted .:/app. Listing the volume without a host path keeps the container's version.

For Python:

YAML
app:
  command: uvicorn main:app --reload
  volumes:
    - ./:/app

For Go: use air (in the container) for live reload, or rebuild with each change.

Most modern frameworks have a watch/dev mode (Next.js, Vite, FastAPI, NestJS). Use it inside the container, mount your code, edit on your host, change appears immediately.


Beyond Local — When to Use Compose vs Not

Use Compose for:
• Local development environments
• Integration testing in CI
• Self-hosted personal/small projects (a Raspberry Pi running Compose works great)
• Quick demo environments

Don't use Compose for:
• Production at any meaningful scale
• Anything needing horizontal scaling, rolling deploys, or self-healing

For production, Kubernetes is the standard. Cloud-managed equivalents:
• ECS / Fargate (AWS) — simpler than K8s
• Cloud Run (GCP) — serverless containers
• App Runner, Lightsail, Railway, Fly.io — easier alternatives

The next two lessons cover Kubernetes — the technology that took the ideas of Docker and made them work at planet scale.

Investment note: every hour spent making your dev environment delightful pays back tenfold. A new engineer who can git clone && docker compose up and start contributing on day one is incredibly productive. Treat your Compose file as engineering infrastructure, not throwaway config.


⁂ Back to all modules