← Back to Blog

Docker Compose Tutorial: Service Orchestration in Practice

Why Docker Compose?

A single container is fine with docker run. Real projects rarely are:

  • An Nginx front end
  • A Node.js / Python / Java backend API
  • A PostgreSQL / MySQL database
  • A Redis cache

Managing those by hand with docker run turns into a nightmare: port mappings, volume mounts, networking, environment variables, startup order — dozens of flags per command, and none of it version-controlled.

Docker Compose describes the whole multi-container stack in one declarative YAML file, and a single command starts, stops, or rebuilds every service. It is the standard tool for local development, CI pipelines, and small production deployments.

Installation and Versions

Docker Desktop (Windows / macOS) ships with Compose. On Linux, install docker-compose-plugin separately and use docker compose (V2, recommended) rather than docker-compose (V1, no longer maintained).

# Verify the installation
docker compose version
# Docker Compose version v2.27.0

Core Concepts

Services

Each service defines one container. The simplest possible example:

# docker-compose.yml
services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html

Networks

Compose creates a network per project by default, and services in the same docker-compose.yml can reach each other by service name — no IP addresses required:

services:
  web:
    image: nginx:alpine
    depends_on:
      - api
  api:
    image: node:20-alpine
    # inside the web container: http://api:3000

You can also define custom networks to isolate tiers:

services:
  web:
    networks:
      - frontend
  api:
    networks:
      - frontend
      - backend
  db:
    networks:
      - backend

networks:
  frontend:
  backend:

Now web cannot reach db directly, because they share no network.

Volumes

Containers are ephemeral — delete one and its data goes with it. Persist data with volumes:

services:
  db:
    image: postgres:16
    volumes:
      # Named volume: managed by Docker, survives container removal
      - pgdata:/var/lib/postgresql/data
      # Bind mount: maps a host path directly, ideal for hot-reloading code
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql

volumes:
  pgdata:

The two mount types:

Type Syntax Use for
Named volume name:/path/in/container Persisting runtime data such as databases
Bind mount ./host/path:/container/path Syncing source code and config during development

Environment Variables

Three ways to configure them:

services:
  api:
    image: myapp:latest
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=info
    # or, from a file
    env_file:
      - .env
    # file format: KEY=value, one per line

Prefer a .env file, and add it to .gitignore so secrets never leak. Compose also reads a .env in the project root automatically for variable interpolation:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}  # read from .env

.env file:

DB_PASSWORD=supersecret

In Practice: A Typical Web Stack

Here is a complete production-grade configuration (Nginx + Node API + PostgreSQL + Redis):

services:
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./certs:/etc/nginx/certs:ro
    depends_on:
      - api
    restart: unless-stopped

  api:
    build:
      context: ./backend
      dockerfile: Dockerfile
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://app:${DB_PASSWORD}@db:5432/app
      - REDIS_URL=redis://redis:6379
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: app
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: redis-server --requirepass ${REDIS_PASSWORD}
    volumes:
      - redisdata:/data
    restart: unless-stopped

volumes:
  pgdata:
  redisdata:

For the reverse-proxy half of that stack, see the Nginx config cheat sheet.

Command Quick Reference

# Start every service (detached)
docker compose up -d

# Show status
docker compose ps

# Tail logs (follow)
docker compose logs -f api

# Rebuild and restart (after a code change)
docker compose up -d --build

# Restart a single service
docker compose restart api

# Shell into a container
docker compose exec api sh

# Stop and remove containers and networks (volumes kept)
docker compose down

# Stop and remove everything including volumes (careful: database data is lost)
docker compose down -v

# Watch resource usage
docker compose stats

More everyday commands are in the Docker command cheat sheet.

Common Patterns and Best Practices

1. Hot Reload in Development

Mount the source and enable file watching:

services:
  api:
    build: ./backend
    volumes:
      - ./backend:/app
      - /app/node_modules  # anonymous volume so the host does not shadow it
    command: npm run dev
    environment:
      - CHOKIDAR_USEPOLLING=true  # works around file watching on Docker Desktop for Windows

2. Multiple Environments

Layer several compose files:

# Base + development overrides
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# Base + production overrides
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Example docker-compose.dev.yml (overrides the command, exposes a debug port):

services:
  api:
    command: npm run dev
    ports:
      - "9229:9229"  # Node.js inspector
    environment:
      - NODE_ENV=development

3. Healthchecks and Startup Order

depends_on only guarantees the container started, not that the service is ready. Combine healthcheck with condition:

services:
  api:
    depends_on:
      db:
        condition: service_healthy

This avoids the classic trap of the API booting before the database is accepting connections.

4. Resource Limits

In production, cap CPU and memory so one container cannot take down the host:

services:
  api:
    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
        reservations:
          memory: 256M

5. Log Rotation

Keep logs from filling the disk:

services:
  api:
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Common Pitfalls

The .env File Is Not Picked Up

  • It must live in the directory where you run docker compose, not necessarily where the compose file sits
  • ${VAR} interpolation happens at YAML parse time, which is a different mechanism from injecting environment into the container
  • Debug with docker compose config to see the fully merged result

Bind Mount Permission Problems (Linux)

When a host file is mounted into a container, a UID mismatch between the container process and the host causes write errors. Two fixes:

# Option 1: run as a non-root user with a matching UID
services:
  api:
    user: "${UID}:${GID}"

# Option 2: chown from an init container after mounting

Port Already Allocated

docker compose up reports port is already allocated, yet docker ps shows nothing:

# Check what holds the host port
# Windows
netstat -ano | findstr :8080

# Linux/macOS
lsof -i :8080

Something outside Docker usually holds it; switch to another port.

Slow File Mounts on Windows

With the WSL2 backend, mounting a Windows path (/c/...) into a container carries significant IO overhead. Put the project inside the WSL2 filesystem (\\wsl$\Ubuntu\home\...) instead — typically 5–10x faster.

Compose vs Swarm vs Kubernetes

Scenario Tool
Single-host development / small deployment Docker Compose
Multi-host clustering / high availability Docker Swarm (lightweight) or Kubernetes (rich ecosystem)
Enterprise-grade production orchestration Kubernetes

A docker-compose.yml deploys straight to Docker Swarm via docker stack deploy, but Kubernetes requires converting to manifests (Kompose can help).

Summary

Docker Compose promotes a multi-container application from a pile of shell commands to declarative, version-controlled, reproducible configuration. Master the four core concepts — services, volumes, networks, environment — add healthchecks, multi-file overrides, and resource limits, and you can cover the vast majority of local development and small production scenarios. When you outgrow a single node, migrate to Kubernetes.

Key point Notes
services Define each container: image, ports, dependencies
volumes Named volumes persist data; bind mounts sync code
networks Services reach each other by name; custom networks isolate
environment A .env file plus interpolation is the best practice
depends_on + healthcheck Guarantees startup order and actual readiness
multi-file override dev.yml / prod.yml per environment

Handy alongside this: convert command-line flags to YAML with our docker run to compose tool, and when you eventually move to a cluster, keep the Kubernetes command cheat sheet close.

Advertisement

Frequently Asked Questions

Does `depends_on` guarantee a service is actually ready?

**No**, and this is the most common misunderstanding. `depends_on` only guarantees the container has **started**, not that the process inside is serving traffic — which is why you get the classic failure where the API boots, tries to reach the database, and the database process is not up yet. The fix is to declare a `healthcheck` on the dependency and use `condition: service_healthy` (or `service_completed_successfully`) on the dependent service. Using `service_started`, the default, is equivalent to not waiting at all.

When should I use a named volume versus a bind mount?

Use **named volumes for runtime data you must keep** (`pgdata:/var/lib/postgresql/data`): Docker manages where it lives, the data survives container removal, and on Linux the performance beats a bind mount. Use **bind mounts to sync code or config during development** (`./backend:/app`), so edits take effect immediately and hot reload works. Watch out that a bind mount **shadows the container's own directory** — a common trick is an anonymous volume (`- /app/node_modules`) to protect dependencies installed inside the image.

Why is my `.env` file being ignored?

Two causes dominate. First, `.env` must sit in the directory **where you run `docker compose`** (normally the project root), not in a subdirectory next to the compose file. Second, keep clear the difference between **variable interpolation** `${VAR}` (substituted while parsing the YAML, for the compose file's own use) and **`environment` / `env_file` injection** (passed to the container process at runtime) — they are not interchangeable. Debug with `docker compose config`, which prints the fully merged and interpolated result, so you can see at a glance whether the variable was substituted.

Can I run Docker Compose in production?

**Yes for small single-host production deployments** — Compose handles that well. Add `restart: unless-stopped`, `healthcheck`, resource caps under `deploy.resources.limits`, log rotation, and environment overlays via `-f docker-compose.yml -f docker-compose.prod.yml`, and the setup is genuinely solid. What it lacks is multi-node scheduling, autoscaling, rolling updates, and self-healing — **once you need multi-host high availability, move to Docker Swarm (lightweight) or Kubernetes (rich ecosystem)**. A compose file deploys to Swarm directly with `docker stack deploy`; moving to Kubernetes requires converting the manifests, with tools like Kompose to help.

← Back to Blog