By now you know how to build and run a single container. But real applications don't run in isolation. You need:
- A database (PostgreSQL)
- A backend API (Express/Fastify)
- A frontend (Next.js)
- Maybe Redis for caching
- A migration service for database schema changes
Running all of these with docker run commands is a nightmare:
# Don't do this
docker network create my-app
docker run -d --name postgres \
--network my-app \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
postgres:16-alpine
docker run -d --name api \
--network my-app \
-e DATABASE_URL=postgres://postgres:secret@postgres:5432/myapp \
-p 3000:3000 \
my-api
docker run -d --name web \
--network my-app \
-e API_URL=http://api:3000 \
-p 8080:3000 \
my-web
docker run --rm \
--network my-app \
-e DATABASE_URL=postgres://postgres:secret@postgres:5432/myapp \
my-api npx prisma migrate deploy
Ten flags, three terminals, and if you get one port wrong everything breaks. Docker Compose fixes this.
What is Docker Compose?
Docker Compose lets you define your entire multi-service application in a single compose.yaml file. One command starts everything:
docker compose up -d
Everything is on the same network. Volumes are configured. Environment variables are set. Dependencies are ordered. All from one file.
Your First compose.yaml
Let's build a real example: a Next.js frontend + Express API + PostgreSQL database.
# compose.yaml
services:
postgres:
image: postgres:16-alpine
container_name: my-app-db
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_USER: app_user
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: my_app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user -d my_app"]
interval: 5s
timeout: 5s
retries: 5
api:
build:
context: ./api
dockerfile: Dockerfile
container_name: my-app-api
restart: unless-stopped
ports:
- "3000:3000"
env_file:
- ./api/.env.production
environment:
DATABASE_URL: postgres://app_user:${DB_PASSWORD}@postgres:5432/my_app
NODE_ENV: production
depends_on:
postgres:
condition: service_healthy
volumes:
- api-logs:/app/logs
web:
build:
context: ./web
dockerfile: Dockerfile
container_name: my-app-web
restart: unless-stopped
ports:
- "8080:3000"
environment:
NEXT_PUBLIC_API_URL: http://api:3000
depends_on:
- api
volumes:
pgdata:
api-logs:
Run it:
docker compose up -d
All three services start. The API waits for PostgreSQL to be healthy. The web app waits for the API. One command, everything connected.
Services
Each block under services: is a container. Compose handles:
- Building: If you specify
build:, Compose runsdocker buildfor you. If you specifyimage:, it pulls from Docker Hub. - Naming:
container_namesets a predictable name. Without it, Compose generates names likemy-app-api-1. - Restart:
restart: unless-stoppedmeans the container auto-restarts on crash but stays stopped if you manually stop it.
Networks
Compose automatically creates a default network for all services in the file. Every service can reach every other service by its service name.
Containers on the default network
┌─────────────────────────────────────┐
│ web (service name) │
│ api (service name) │
│ postgres (service name) │
│ │
│ web → http://api:3000 │
│ api → postgres:5432 │
└─────────────────────────────────────┘
The magic: containers communicate by service name, not IP address. Compose handles DNS resolution. From the API container, postgres:5432 resolves to the PostgreSQL container's IP.
You don't need to create or join networks manually. Compose does it for you.
Volumes
Volumes persist data across container restarts. Without volumes, database data is lost when the container stops.
services:
postgres:
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
This creates a named volume pgdata managed by Docker. It persists even after docker compose down. To destroy it:
docker compose down -v
Bind mounts are another option — they map a host directory instead of a Docker volume:
services:
api:
volumes:
- ./api:/app # Development: use local source code
- /app/node_modules # Don't override node_modules
Bind mounts are useful in development where you want live code reloading. In production, use named volumes.
Environment Variables: env_file vs environment
This is a common source of confusion. Here's the difference:
environment
Inline key-value pairs:
environment:
NODE_ENV: production
DATABASE_URL: postgres://app_user:password@postgres:5432/my_app
For secrets, use variable references from a .env file in the project root:
environment:
DB_PASSWORD: ${DB_PASSWORD}
env_file
Reference a file containing environment variables:
env_file:
- ./api/.env.production
When to Use Which
| Approach | Use Case |
|----------|----------|
| environment | Non-sensitive, short values, or referencing variables |
| env_file | Many variables, environment-specific configs |
| .env file (project root) | Shared variables used across multiple services |
Compose automatically loads a .env file from the project root. Variables from .env can be referenced with ${VAR_NAME} syntax in the compose file.
Security note: Never commit .env or .env.production to git. Add them to .gitignore. Use .env.example as a template.
Restart Policies
| Policy | Behavior |
|--------|----------|
| no | Never restart (default) |
| always | Always restart, even if stopped manually |
| unless-stopped | Restart unless manually stopped (recommended) |
| on-failure | Restart only if container exits with non-zero code |
For production services:
restart: unless-stopped
This is the safest default. Containers restart on crash or system reboot. Manual stops are respected.
Healthchecks
A docker run doesn't tell you if your application is actually working. It only tells you the process started. Healthchecks solve this:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 15s
For PostgreSQL, use pg_isready:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app_user -d my_app"]
interval: 5s
timeout: 5s
retries: 5
Healthchecks are how depends_on with conditions works:
depends_on:
postgres:
condition: service_healthy
Without the healthcheck, depends_on only waits for the container to start — not for PostgreSQL to be ready to accept connections. PostgreSQL starts in ~1 second but takes 3-5 seconds to be ready for queries. Without a healthcheck condition, your API connects and fails immediately.
Useful Compose Commands
# Start all services
docker compose up -d
# View logs from all services
docker compose logs -f
# View logs from a specific service
docker compose logs -f api
# Rebuild and start
docker compose up -d --build
# Stop all services
docker compose down
# Stop and remove volumes
docker compose down -v
# Run a one-off command in a service
docker compose run --rm api npx prisma migrate deploy
# List running services
docker compose ps
# Restart a single service
docker compose restart api
# Scale a service (Compose v2+)
docker compose up -d --scale api=3
Development vs Production: Separate Compose Files
Compose supports merging multiple files. The convention:
# compose.yaml (base — shared config)
services:
api:
image: my-api
ports:
- "3000:3000"
environment:
NODE_ENV: production
# compose.dev.yaml (development overrides)
services:
api:
build:
context: ./api
dockerfile: Dockerfile.dev
volumes:
- ./api/src:/app/src
environment:
NODE_ENV: development
Use them together:
# Development
docker compose -f compose.yaml -f compose.dev.yaml up -d
# Production
docker compose -f compose.yaml -f compose.prod.yaml up -d
The override pattern keeps production config clean while enabling hot-reload and debug ports in development.
The .env File
Create .env in your project root (next to compose.yaml):
DB_PASSWORD=supersecret
API_KEY=abc123
Compose loads this automatically. Reference variables in compose.yaml:
environment:
DB_PASSWORD: ${DB_PASSWORD}
Add .env to .gitignore. Create .env.example with placeholder values:
DB_PASSWORD=change-me
API_KEY=change-me
Putting It All Together
Here's what happens when you run docker compose up -d:
- Compose reads
compose.yamland.env - It creates the default network if it doesn't exist
- It creates named volumes if they don't exist
- It builds images for services with
build:defined - It pulls images for services with
image:defined - It starts services respecting
depends_onorder and healthcheck conditions - It attaches DNS resolution so services find each other by name
All of this from one file. No shell scripts, no network setup, no manual volume creation.
What's Next?
Compose works great for local development and simple deployments. But production has stricter requirements: zero-downtime migrations, proper dependency ordering, and health-aware service coordination.
In the next article, we'll take this Compose setup and harden it for production — covering migration patterns, all three depends_on conditions, and production-ready healthcheck strategies.
This article is part of the DevOps from Zero series.
| Article | Topic | |---------|-------| | 1. What is Docker and Why Should You Care? | | | 2. Writing Your First Dockerfile — The Right Way | | | 3. Multi-Stage Docker Builds — Shrink Your Image from 900MB to 180MB | | | 4. Docker Compose — Stop Running 10 docker run Commands | ← You are here | | 5. Docker Compose for Production | Coming soon | | 6. Linux Survival Guide for Developers Coming from macOS | Coming soon | | 7. GitHub Actions From Zero — Build Your First Pipeline | Coming soon | | 8. Docker + GitHub Actions — Build, Push & Deploy Automatically | Coming soon |