Last lesson you stood up a web app, a database and a cache with a pile of docker run commands. It works... but it's tedious, and one typo means you're starting over. So what if you could describe the whole multi-container app in one file and start everything with one command? That's EXACTLY what Docker Compose does.
What is Docker Compose?
Docker Compose is a tool for defining and running apps made of several containers. You describe all your services, networks and volumes in one YAML file, then run the whole thing with a couple of simple commands. The current docs call that file compose.yaml, but plenty of projects still use the older docker-compose.yml name and Compose happily reads either one.
Example
# Start all services defined in your compose file
docker compose up
# Stop everything
docker compose down
Yep, that's it. One command starts your whole app stack.
Your First compose.yaml
Here's a simple example with a web app and a PostgreSQL database.
Create a local .env file with POSTGRES_PASSWORD= followed by a strong, unique password. Add .env to .gitignore. Compose substitutes that value when it starts the stack. Do not commit the .env file or paste a real password into compose.yaml.
Let's pick it apart piece by piece.
Services: Your Containers
The services section lists every container your app needs. Each service gets a name (like web or db), and the other services can use that name as a hostname.
Example
services:
web:
build: . # Build from Dockerfile in current directory
ports:
- "3000:3000" # Map port 3000
api:
image: myapp-api:latest # Use a pre-built image
ports:
- "8080:8080"
Ports: Expose Services
The ports section maps ports on your machine to ports in the container, same deal as the -p flag.
Example
services:
web:
ports:
- "3000:3000" # host:container
- "9229:9229" # You can map multiple ports (e.g., for debugging)
Environment Variables
Here's how you set environment variables for your containers.
Or you can use an .env file and keep your secrets out of the compose file altogether.
Example
services:
web:
env_file:
- .env
Volumes: Persist Data
Set up volumes so your data sticks around.
Example
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data # Named volume
web:
build: .
volumes:
- ./src:/app/src # Bind mount for development
# Declare named volumes at the bottom
volumes:
pgdata:
depends_on: Control Startup Order
depends_on makes sure your services start in the right order.
Example
services:
web:
build: .
depends_on:
- db
- cache
# web starts AFTER db and cache are running
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
cache:
image: redis:7
Docker Compose builds a network for your services automatically. Every service can reach every other one using its service name as a hostname, so most of the time you don't have to define networks at all.
Example
services:
web:
environment:
# "db" resolves to the db service automatically
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/myapp
# "cache" resolves to the cache service
- REDIS_URL=redis://cache:6379
db:
image: postgres:16
cache:
image: redis:7
A Complete Real-World Example
Here's a full-stack app with a Next.js frontend, an Express API, a PostgreSQL database and a Redis cache.
Example
services:
frontend:
build: ./frontend
ports:
- "3000:3000"
environment:
- API_URL=http://api:8080
depends_on:
- api
api:
build: ./api
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/myapp
- REDIS_URL=redis://cache:6379
depends_on:
- db
- cache
volumes:
- ./api/src:/app/src # Live reload in development
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=myapp
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5432:5432" # Expose for local database tools
cache:
image: redis:7
ports:
- "6379:6379"
volumes:
pgdata:
Essential Docker Compose Commands
Example
# Start all services (with live output)
docker compose up
# Start in the background
docker compose up -d
# Stop and remove all containers
docker compose down
# Stop and remove containers + volumes (deletes data!)
docker compose down -v
# Rebuild images before starting
docker compose up --build
# View logs for all services
docker compose logs
# View logs for one service
docker compose logs api
# Follow logs in real-time
docker compose logs -f
# List running services
docker compose ps
# Open a shell inside a running service
docker compose exec api sh
# Restart a specific service
docker compose restart api
Development vs Production
You can use separate compose files for separate environments.
Example
# Development (default)
docker compose up
# Production (with override file)
docker compose -f compose.yaml -f compose.prod.yaml up -d
A production override file might strip out the bind mounts, add resource limits and swap some environment variables.
TL;DR
Docker Compose describes multi-container apps in one compose.yaml file (older projects call it docker-compose.yml)
services lists each container (web, db, cache, etc.)
ports maps ports on your machine to ports in the container
environment sets environment variables (use env_file for secrets)
volumes keeps data around and syncs your code live during development
depends_on controls the order services start in
Services find each other by name automatically (no IP addresses needed)
docker compose up starts everything, and docker compose down stops everything
What's Next?
Your Docker setup is humming along now, but your images are probably bigger and slower to build than they need to be. Next lesson gets into slimming them down with tricks like multi-stage builds, .dockerignore and smarter layer caching, so your images shrink and your builds speed up...