DevOps & Platform

Docker Compose for Local Development: Multi-Container Environments

Beginner50 min to complete13 min read

Run a full application stack locally with a single command — learn Docker Compose's service model, networking, volumes, health checks, environment config, and the patterns that keep local dev close to production.

Before you begin

  • Understand Docker basics — images, containers, Dockerfile, port mapping
  • Docker Desktop or Docker Engine with the Compose plugin installed
Docker
Docker Compose
Local Development
DevOps
Containers

Docker Compose for Local Development: Multi-Container Environments

Most applications aren't a single container. They're an API server, a database, a cache, maybe a background worker. Running each manually with docker run — remembering flags, network names, and startup order — gets tedious immediately. Docker Compose solves this: one YAML file describes the whole stack, one command brings it up.


What Docker Compose Is (and Isn't)

Compose is a tool for defining and running multi-container applications on a single host. It's designed for local development, integration testing, and simple single-server deployments.

It is not Kubernetes. It doesn't do:

  • Multi-node scheduling
  • Automatic failover
  • Rolling updates at scale
  • Auto-scaling

For production at scale, that's Kubernetes' job. Compose's value is keeping local development fast and close to the production topology.


Compose File Structure

The file is called docker-compose.yml (or compose.yml — both work). It lives at the root of your project.

yaml
1services:        # one entry per container
2  api:
3    ...
4  db:
5    ...
6
7networks:        # optional — Compose creates a default network
8  ...
9
10volumes:         # named volumes
11  ...

A Minimal Example — API + Database

yaml
1services:
2  api:
3    build: .                          # Build from Dockerfile in current directory
4    ports:
5      - "3000:3000"
6    environment:
7      - DATABASE_URL=postgresql://postgres:secret@db:5432/myapp
8    depends_on:
9      db:
10        condition: service_healthy    # Wait until db is actually ready
11
12  db:
13    image: postgres:16-alpine
14    environment:
15      POSTGRES_DB: myapp
16      POSTGRES_USER: postgres
17      POSTGRES_PASSWORD: secret
18    volumes:
19      - db_data:/var/lib/postgresql/data
20    healthcheck:
21      test: ["CMD-SHELL", "pg_isready -U postgres"]
22      interval: 5s
23      timeout: 5s
24      retries: 5
25
26volumes:
27  db_data:

Bring it up:

bash
docker compose up               # Foreground, all logs streamed
docker compose up -d            # Detached (background)
docker compose up --build       # Force rebuild images before starting

Essential Commands

bash
1docker compose up -d            # Start all services in background
2docker compose down             # Stop and remove containers (keeps volumes)
3docker compose down -v          # Stop, remove containers AND volumes
4docker compose ps               # Show running services
5docker compose logs             # All service logs
6docker compose logs -f api      # Follow logs for a specific service
7docker compose logs --tail 50   # Last 50 lines from all services
8docker compose exec api bash    # Shell inside running service
9docker compose exec db psql -U postgres myapp  # Run a command in a service
10docker compose restart api      # Restart a specific service
11docker compose pull             # Pull latest images for all services
12docker compose build            # Build all services with build context
13docker compose build api        # Build only the api service
14docker compose stop             # Stop containers without removing them
15docker compose start            # Start stopped containers

Networking — How Services Find Each Other

Compose automatically creates a network for your project. All services on that network can reach each other by service name.

yaml
1services:
2  api:
3    image: myapi
4    # Can connect to 'db' at postgres://postgres:secret@db:5432/myapp
5    # 'db' resolves to the container's IP automatically
6
7  db:
8    image: postgres:16-alpine

No IP addresses, no manual network setup. Service names are the hostnames. This is the same model as Kubernetes Services — db in Compose maps to db.default.svc.cluster.local in K8s.

Custom networks

yaml
1services:
2  api:
3    networks:
4      - frontend
5      - backend
6
7  db:
8    networks:
9      - backend           # db is only reachable from backend
10
11  nginx:
12    networks:
13      - frontend          # nginx is only reachable from frontend
14
15networks:
16  frontend:
17  backend:

Using separate networks provides isolation: nginx can't directly reach db, only api can.


Volumes — Persistent and Shared Data

Named volumes (persistent across down)

yaml
1services:
2  db:
3    image: postgres:16-alpine
4    volumes:
5      - db_data:/var/lib/postgresql/data   # named volume
6
7volumes:
8  db_data:    # Compose manages this volume

docker compose down removes containers but keeps db_data. Use docker compose down -v to also delete volumes.

Bind mounts (hot-reload in development)

yaml
services:
  api:
    build: .
    volumes:
      - ./src:/app/src       # Mount local src/ into container
      - ./config:/app/config

Changes to src/ on your host appear immediately inside the container. Combined with a file watcher (nodemon, air, uvicorn --reload), this gives you hot-reload without rebuilding.

Read-only mounts

yaml
volumes:
  - ./config/nginx.conf:/etc/nginx/nginx.conf:ro

:ro prevents the container from modifying the mounted file.


Environment Variables

Inline in the Compose file

yaml
services:
  api:
    environment:
      NODE_ENV: development
      LOG_LEVEL: debug
      DATABASE_URL: postgresql://postgres:secret@db:5432/myapp

From a .env file

Compose automatically reads .env in the same directory and substitutes ${VARIABLE} references:

bash
# .env
POSTGRES_PASSWORD=secret
API_PORT=3000
yaml
1services:
2  api:
3    ports:
4      - "${API_PORT}:3000"
5  db:
6    environment:
7      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

env_file — load an entire file into a service

yaml
services:
  api:
    env_file:
      - .env
      - .env.local          # Local overrides — add to .gitignore

Never commit .env files with real credentials. Commit .env.example with placeholder values; developers copy it to .env locally.


Health Checks and depends_on

Without health checks, depends_on only waits for the container to start — not for the service inside it to be ready. A database container starts in milliseconds; PostgreSQL takes a few seconds to accept connections.

yaml
1services:
2  api:
3    depends_on:
4      db:
5        condition: service_healthy    # Wait until db healthcheck passes
6      redis:
7        condition: service_healthy
8
9  db:
10    image: postgres:16-alpine
11    healthcheck:
12      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
13      interval: 5s
14      timeout: 5s
15      retries: 5
16      start_period: 10s              # Grace period before first check
17
18  redis:
19    image: redis:7-alpine
20    healthcheck:
21      test: ["CMD", "redis-cli", "ping"]
22      interval: 5s
23      timeout: 3s
24      retries: 5

The three condition values:

  • service_started — container is running (default, not very useful)
  • service_healthy — healthcheck is passing
  • service_completed_successfully — for init containers that should exit 0

A Full Stack Example — API + PostgreSQL + Redis + nginx

yaml
1services:
2  nginx:
3    image: nginx:alpine
4    ports:
5      - "80:80"
6    volumes:
7      - ./nginx.conf:/etc/nginx/nginx.conf:ro
8    depends_on:
9      - api
10
11  api:
12    build:
13      context: .
14      dockerfile: Dockerfile
15    environment:
16      NODE_ENV: development
17      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
18      REDIS_URL: redis://redis:6379
19    depends_on:
20      db:
21        condition: service_healthy
22      redis:
23        condition: service_healthy
24    volumes:
25      - ./src:/app/src                  # Hot-reload
26
27  db:
28    image: postgres:16-alpine
29    environment:
30      POSTGRES_DB: ${POSTGRES_DB}
31      POSTGRES_USER: ${POSTGRES_USER}
32      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
33    volumes:
34      - db_data:/var/lib/postgresql/data
35      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro  # Run on first start
36    healthcheck:
37      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
38      interval: 5s
39      timeout: 5s
40      retries: 5
41
42  redis:
43    image: redis:7-alpine
44    command: redis-server --appendonly yes
45    volumes:
46      - redis_data:/data
47    healthcheck:
48      test: ["CMD", "redis-cli", "ping"]
49      interval: 5s
50      timeout: 3s
51      retries: 5
52
53volumes:
54  db_data:
55  redis_data:

Compose Overrides — Dev vs CI vs Production

You can layer multiple Compose files using -f. The base file describes the service topology; override files customise it per environment.

yaml
# docker-compose.yml (base)
services:
  api:
    image: myapp:${TAG:-latest}
    environment:
      NODE_ENV: production
yaml
1# docker-compose.dev.yml (development overrides)
2services:
3  api:
4    build: .                          # Build locally instead of pulling
5    environment:
6      NODE_ENV: development
7    volumes:
8      - ./src:/app/src                # Hot-reload
9    ports:
10      - "9229:9229"                   # Node debugger port
yaml
1# docker-compose.ci.yml (CI overrides)
2services:
3  api:
4    build:
5      context: .
6      cache_from:
7        - type=gha                    # GitHub Actions cache
bash
# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# CI
docker compose -f docker-compose.yml -f docker-compose.ci.yml run api npm test

Compose also automatically merges docker-compose.override.yml if it exists — useful for per-developer local tweaks (add to .gitignore).


Resource Limits

yaml
1services:
2  api:
3    deploy:
4      resources:
5        limits:
6          cpus: "0.5"
7          memory: 512M
8        reservations:
9          cpus: "0.25"
10          memory: 256M

Note: docker compose v2 applies deploy.resources limits via Linux cgroups in standalone mode — no extra flags needed. In Kubernetes, use resources.requests/limits in pod specs instead; Compose resource limits don't carry over to K8s manifests.


Profiles — Optional Services

yaml
1services:
2  api:
3    build: .
4
5  db:
6    image: postgres:16-alpine
7
8  pgadmin:
9    image: dpage/pgadmin4
10    profiles:
11      - tools                 # Only starts when tools profile is active
12
13  mailhog:
14    image: mailhog/mailhog
15    profiles:
16      - tools
bash
docker compose up                         # Starts api + db only
docker compose --profile tools up         # Starts api + db + pgadmin + mailhog

Profiles let you keep optional development tools (admin UIs, mock services, test databases) in the Compose file without starting them by default.


Common Issues

Port already in use:

bash
docker compose down                       # Stop previous stack
lsof -i :5432                            # Find what's using the port

Container starts but service isn't ready: Add a healthcheck to the dependency and use condition: service_healthy in depends_on.

Volume data from old run causes issues:

bash
docker compose down -v                    # Delete containers + volumes
docker compose up -d

Changes to Dockerfile not reflected:

bash
docker compose up --build                 # Force rebuild

What's Next

You've completed the Container Foundations learning path:

  1. Docker Fundamentals — images, containers, Dockerfile, volumes, networking
  2. Docker Multi-Stage Builds — slim images, layer cache, distroless, non-root
  3. Docker Compose (this tutorial) — multi-container stacks, health checks, environment config

Next: Kubernetes. Pods are containers. Services are your Compose network. ConfigMaps and Secrets replace .env. PersistentVolumeClaims replace named volumes. The mental model transfers directly — Kubernetes is Compose at cluster scale.

Start with the Platform Engineering Roadmap to see how containers fit into the full learning path.

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.