NovuSpark
All articles
ContainerizationMay 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.

project network (created automatically)web:8080 → :80dbpgdata volumeredisweb reaches db and redis by service name — no manual network setup
Fig. 1 — one docker compose up creates the network, builds/pulls every image, and wires name-based resolution automatically

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. This is the exact same HEALTHCHECK mechanism introduced in the production Dockerfiles post — Compose's condition: service_healthy is simply what reads that healthcheck status and acts on it.

Scaling a service locally

docker compose up --scale web=3

Compose can run multiple instances of a single service, useful for locally testing load-balancing behavior or verifying an application handles concurrent instances correctly (no in-memory session state that breaks the moment a second instance exists, for instance) before it ever reaches a real multi-instance production deployment. This isn't a substitute for Kubernetes' actual scheduling and load-balancing — covered starting with our Kubernetes series — but it's a genuinely useful local sanity check for exactly the class of bug that only appears once more than one instance of a service is running.

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.

For more than two variants (dev, CI, staging-like local testing), named files with -f make the intent explicit rather than relying on the automatic override convention:

docker compose -f docker-compose.yml -f docker-compose.ci.yml up

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 blog: real values live in an environment-specific, often git-ignored file; the structure that references them stays committed and shared.

Useful day-to-day commands beyond up

docker compose logs -f web       # follow one service's logs specifically
docker compose exec web sh       # shell into a running service
docker compose down -v           # stop everything AND remove volumes — careful, this deletes local data
docker compose ps                # status of every service in this project

docker compose down -v is worth calling out specifically: the -v flag removes named volumes along with the containers, which means your local database's data is genuinely gone, not just the containers. docker compose down alone (no -v) stops and removes containers but leaves volumes — and therefore your data — intact for next time.

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.

Resource limits in Compose, for realistic local testing

Compose supports the same resources concept covered for Kubernetes Pods elsewhere in this blog — worth setting locally specifically to catch a memory leak or an oversized query before it ever reaches a production environment with real limits enforced:

services:
  web:
    build: .
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "0.5"

Testing an application against the same constrained resources it'll actually run under in production — rather than an unconstrained local machine with far more memory and CPU available than a production container will ever get — surfaces a resource problem during local development, where it's cheap to fix, instead of after a production deploy, where it isn't.

Profiles: running only a subset of services

A Compose file covering an entire application's services doesn't always need every service running for every task — a frontend developer working purely on UI code rarely needs a background worker or a full analytics pipeline running locally too:

services:
  web:
    build: .
  worker:
    build: ./worker
    profiles: ["full"]
  analytics:
    build: ./analytics
    profiles: ["full"]
docker compose up              # starts only web — profiles not activated
docker compose --profile full up  # starts everything, including worker and analytics

Services tagged with a profile only start when that profile is explicitly requested — a genuinely useful way to keep a large multi-service Compose file usable for narrower, faster local development tasks without needing a second, stripped-down Compose file maintained separately alongside the full one.

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.
  • docker compose down -v deletes volumes; plain down doesn't — worth knowing the difference before you lose local data you meant to keep.
  • 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.