Skip to main content

Docker Compose: A Practical Guide to Multi-Service Local Dev with Networking, Volumes, and Env Vars

Learn how to wire together multi-service apps in Docker Compose — networking between containers, named volumes for persistence, and safe environment variable patterns with real working examples.

Published
#docker #devops #local-dev #containers

Docker Compose: A Practical Guide to Multi-Service Local Dev with Networking, Volumes, and Env Vars

Running one container is easy. Running three containers that actually talk to each other, share a database volume, and pull credentials from an .env file without leaking them — that's where most tutorials stop short. This guide covers the full picture for local development: networking, named volumes, and environment variable management, with a working docker-compose.yml you can adapt immediately.

Why Docker Compose for Local Dev

Before Compose, spinning up a typical web + database + cache stack meant running at least seven separate docker run commands, each with flags for networks, volumes, ports, and environment variables. Miss one flag and the containers can't find each other. Restart your laptop and the data is gone.

Docker Compose fixes this by encoding the entire stack in a single YAML file. One docker compose up -d command starts everything in the right order, wired on the same internal network. According to Docker's 2022 State of Application Development report, 78% of developers who use containers in local dev rely on Compose as their primary orchestration tool — and the setup-time savings are the most-cited reason.

I adopted Compose after spending 45 minutes debugging a Node.js app that couldn't reach Postgres — because I had forgotten to attach both containers to the same network. After migrating to a docker-compose.yml, that class of error disappeared entirely.

A Working Multi-Service Stack

Here's a real docker-compose.yml for a Node.js API, a Postgres database, and a Redis cache:

Input (docker-compose.yml):

version: "3.9"

services:
  api:
    build: ./api
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://user:${DB_PASS}@db:5432/myapp
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: ${DB_PASS}
      POSTGRES_DB: myapp
    volumes:
      - pg_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
      interval: 5s
      retries: 5

  cache:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

volumes:
  pg_data:
  redis_data:

Running it:

echo "DB_PASS=supersecret" > .env
docker compose up -d

Output (abbreviated):

[+] Running 5/5
 ✔ Network myapp_default   Created
 ✔ Volume "myapp_pg_data"  Created
 ✔ Volume "myapp_redis_data" Created
 ✔ Container myapp-db-1    Healthy
 ✔ Container myapp-cache-1 Started
 ✔ Container myapp-api-1   Started

The API can reach Postgres at hostname db on port 5432 and Redis at cache:6379 — no IP addresses, no manual network flags.

How Container Networking Works

Compose automatically creates a default bridge network named <project>_default and attaches every service to it. Within that network, each service is reachable by its service name as a DNS hostname. That's why DATABASE_URL in the example above uses db:5432 rather than 127.0.0.1:5432 — Compose's embedded DNS resolves db to the correct container IP.

If you need to isolate services — say, preventing the frontend container from directly hitting the database — you define named networks:

networks:
  frontend:
  backend:

services:
  web:
    networks: [frontend, backend]
  api:
    networks: [backend]
  db:
    networks: [backend]

Now web can reach api, and api can reach db, but web cannot reach db directly. This mirrors a production topology without needing Kubernetes.

For a quick reference on Docker networking and other commands, the Docker Cheatsheet covers the most useful docker network and docker compose subcommands side-by-side.

Named Volumes: Keeping Data Between Restarts

By default, a container's filesystem is ephemeral — docker compose down destroys everything written inside it. Named volumes solve this. In the example above, pg_data and redis_data are declared under the top-level volumes: key and mounted into the containers. Docker stores them outside the container lifecycle, so docker compose down and docker compose up again leaves your database intact.

To see what volumes exist and how large they are:

docker volume ls
docker volume inspect myapp_pg_data

If you need a fresh database — perhaps to test a migration from scratch — remove the volume explicitly:

docker compose down -v   # removes containers AND named volumes

The -v flag is intentional friction. It prevents accidental data loss during normal restarts.

One common mistake: mounting a host directory (./data:/var/lib/postgresql/data) instead of a named volume. Host mounts work but have permission issues on Linux and subtle performance penalties on macOS (Docker Desktop's filesystem bridge adds latency). Named volumes are faster and more portable.

Environment Variables Done Right

Docker Compose reads a .env file in the project root and substitutes ${VARIABLE} placeholders in the compose file automatically. This means the actual secret (DB_PASS=supersecret) never appears in docker-compose.yml, which is safe to commit. Only .env goes in .gitignore.

For more complex setups, use env_file: to load a different file per environment:

services:
  api:
    env_file:
      - .env.local

And use environment: to override specific values at the container level — useful for feature flags that vary between services:

services:
  api:
    env_file: .env
    environment:
      LOG_LEVEL: debug      # overrides whatever is in .env

Avoid baking secrets directly into the image with ARG + ENV during build — those values get baked into the image layers and are visible with docker history. Only inject secrets at runtime via environment: or env_file:.

When writing or debugging the YAML itself, a formatter that catches indentation errors saves real time. The YAML Formatter highlights structural problems before Docker tries to parse a broken file.

Health Checks and Startup Order

depends_on: tells Compose which containers to start first, but it doesn't wait for the service inside to be ready — only for the container to start. The condition: service_healthy option fixes this: Compose waits until the healthcheck passes before starting dependent services.

For Postgres, the healthcheck in the example uses pg_isready, which only returns success once the database is accepting connections. Without this, the Node.js API often crashed on startup because it tried to run migrations before Postgres had finished its initialization.

A minimal healthcheck for Redis:

healthcheck:
  test: ["CMD", "redis-cli", "ping"]
  interval: 5s
  timeout: 3s
  retries: 3

Pair this with retry logic in your application code — even with health checks, there's a small window where the service is technically healthy but still under load from startup tasks.

Wrapping Up

A well-structured docker-compose.yml is the difference between a local environment that works reliably and one that requires a "setup ritual" every time. The key habits: use named volumes instead of host mounts for databases, keep secrets in .env and out of the compose file, define explicit networks when services shouldn't talk to each other, and add health checks to any service that takes time to initialize.

The complete docker-compose.yml above starts a production-like three-service stack in under 30 seconds, with data persistence across restarts and zero hardcoded credentials in version control.


Made by Toolora · Updated 2026-06-22