Docker Compose — Stop Running 10 docker run Commands
A practical guide to Docker Compose covering services, networks, volumes, environment configuration, restart policies, and a real multi-service example with Next.js, Express, and PostgreSQL.

Mustafa Tawab

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 |
Related Articles
All articlesDocker Compose for Production — depends_on, Healthchecks & Migration Patterns
Production-hardening your Compose setup with all three depends_on conditions, zero-downtime migration patterns, and a battle-tested compose.yaml for Next.js, Express, and PostgreSQL.
Writing Your First Dockerfile — The Right Way
A production-ready Dockerfile for Node.js applications, covering every instruction, layer caching optimization, and the dist/server.js vs npm start trap that most tutorials get wrong.
What is Docker and Why Should You Care?
A beginner-friendly introduction to Docker covering containers vs VMs, images, layers, Docker Hub, and the core concepts you need before writing your first Dockerfile.