NovuSpark
All articles

May 1, 2026 · NovuSpark Team

Docker Compose for Local Multi-Container Development

This is the fourth post in our Docker fundamentals series, building directly on networking.

A real application is rarely just one container. A typical setup needs the app itself, a database, maybe Redis for caching, maybe a background worker — each with its own image, its own environment variables, its own network configuration. Standing that up by hand means a sequence of docker network create, docker run (repeated per service, each with several flags), in a specific order, that someone has to remember and re-type correctly every time. Compose replaces all of that with one declarative file.

The file itself

# docker-compose.yml
services:
  web:
    build: .
    ports:
      - "8080:80"
    environment:
      DATABASE_URL: postgresql://postgres:example@db:5432/mydb
    depends_on:
      - db
 
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: example
    volumes:
      - pgdata:/var/lib/postgresql/data
 
  redis:
    image: redis:7-alpine
 
volumes:
  pgdata:
docker compose up

One command replaces building the web image, creating a network, starting db with the right environment variables and a persistent volume, starting redis, and connecting all three together with name-based DNS resolution — exactly the networking behavior covered in the previous post in this series, created automatically by Compose without anyone typing docker network create.

What's actually happening underneath up

  • A dedicated network is created automatically, scoped to this Compose project. web can reach db and redis by service name — the exact same user-defined bridge network mechanics from the previous post, just created for you.
  • build: . tells Compose to build an image from the Dockerfile in the current directory, rather than pulling a pre-built one — genuinely useful for local development, where you want your own code changes reflected without a manual docker build step first.
  • depends_on controls start order (db starts before web), but — this trips people up constantly — it does not wait for db to actually finish initializing and become ready to accept connections, only for the container process to have started. An application that immediately tries to connect to a database that's still initializing needs its own retry logic, or an explicit healthcheck-based dependency (below), not just depends_on alone.

Doing dependency ordering properly

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: example
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
 
  web:
    build: .
    depends_on:
      db:
        condition: service_healthy

condition: service_healthy makes web actually wait for db's healthcheck to pass — not just for the container to have started — before Compose starts web. This is the correct fix for the "my app crashes on startup because the database wasn't ready yet" problem, rather than papering over it with an arbitrary sleep in an entrypoint script.

Overriding configuration per environment

# docker-compose.yml — shared, committed configuration
services:
  web:
    build: .
    environment:
      NODE_ENV: production
# docker-compose.override.yml — loaded automatically alongside the base file, for local dev
services:
  web:
    environment:
      NODE_ENV: development
    volumes:
      - ./src:/app/src
    ports:
      - "9229:9229"  # Node.js debugger port, not needed outside local dev

Compose automatically merges docker-compose.override.yml on top of docker-compose.yml if it's present — this is the standard pattern for keeping genuinely shared, production-relevant configuration in the base file, while local-only conveniences (a mounted source directory for live-reload, a debugger port) live in a file that never needs to be part of any real deployment.

Environment variables and .env files

# .env (add this to .gitignore if it contains real secrets)
POSTGRES_PASSWORD=example
APP_PORT=8080
services:
  web:
    ports:
      - "${APP_PORT}:80"
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

Compose loads a .env file in the same directory automatically, making ${VARIABLE} substitution available throughout the Compose file — the same instinct as Ansible Vault or Terraform variables from earlier in this series: real values live in an environment-specific, often git-ignored file; the structure that references them stays committed and shared.

What Compose is, and isn't, the right tool for

Compose is unambiguously the right tool for local development and often for CI test environments — spinning up a realistic multi-container setup quickly, reproducibly, on one machine. It is not a production orchestration tool: it has no meaningful concept of rolling deployments, multi-host scheduling, or automated failover. That's the job Kubernetes exists to do, which is exactly where our Kubernetes fundamentals series picks up.

What to actually remember from this post

  • One Compose file replaces a sequence of manual docker network/docker run commands — including the networking setup covered in the previous post.
  • depends_on controls start order, not readiness — use a healthcheck and condition: service_healthy for genuine "wait until this is actually ready" behavior.
  • docker-compose.override.yml loads automatically, and is the right place for local-only conveniences that shouldn't leak into shared configuration.
  • Compose is for local development, not production orchestration — that's a different problem, with a different tool.

Next in the series: Docker Image Security and Vulnerability Scanning, the final post — closing the loop on the non-root, minimal-base-image practices introduced earlier.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.