What is Docker Compose?
Docker Compose is the standard way to define and run multi-container Docker applications. Instead of typing a long docker run for each service plus a docker network create plus volume management, you describe the whole stack in a YAML file and bring it up with one command.
Theory
TL;DR
- A YAML file (default name:
compose.yamlor legacydocker-compose.yml) describes services, networks, and volumes for your app. - One command brings everything up (
docker compose up), one tears it down (docker compose down). - Each service becomes a container; Compose creates a private bridge network so services find each other by name (
db,api). - Standard for local dev and single-host deploys. For multi-host production, you usually go to Swarm or Kubernetes.
docker compose(v2, Go plugin) is current.docker-compose(v1, Python) is deprecated since 2023.
Quick example
# compose.yaml
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
depends_on:
- api
api:
build: ./api
environment:
DATABASE_URL: postgres://postgres:devpass@db:5432/app
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: devpass
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
volumes:
pgdata:$ docker compose up -d
[+] Running 4/4
✔ Network myapp_default Created
✔ Container myapp-db-1 Healthy
✔ Container myapp-api-1 Started
✔ Container myapp-web-1 StartedOne command, three services, a network, and a volume. Reproducible on any machine that has Docker.
The compose file's main blocks
services: # one block per container
<name>:
image: ... # OR build: ./path (build from a Dockerfile)
container_name: ... # optional fixed name; otherwise <project>-<service>-<index>
command: ["..."] # override CMD
entrypoint: ["..."] # override ENTRYPOINT
ports: ["8080:80"] # publish ports HOST:CONTAINER
expose: ["5000"] # internal-only, no host publish
environment: # env vars (map or list)
KEY: value
env_file: .env # env vars from a file
volumes: # mounts (named volumes or bind mounts)
- data:/var/lib/...
- ./conf:/etc/conf:ro
networks: [frontend, backend]
depends_on: # start order + health gate
<other-service>:
condition: service_healthy
restart: unless-stopped
healthcheck: { test: [...], interval: 30s }
deploy: # resource limits, replicas (works in Compose too since v2)
resources:
limits: { cpus: "0.5", memory: 512M }
networks: # named networks (default: one bridge per project)
frontend:
backend:
volumes: # named volumes
data:Most real apps use a subset. The 80% subset is services with image/build, ports, environment, volumes, depends_on.
Lifecycle commands
docker compose up # start (foreground, streams logs)
docker compose up -d # start detached
docker compose down # stop and remove containers + network
docker compose down -v # also delete volumes (DESTRUCTIVE)
docker compose ps # list services for this project
docker compose logs -f # tail logs from all services
docker compose logs api # only one service
docker compose exec api sh # shell into a running service
docker compose run --rm api npm test # run a one-off command in a new container
docker compose build # rebuild images for services with build:
docker compose pull # pull images for services with image:
docker compose restart api # restart one service
docker compose stop / start # without removing
docker compose config # validate and print resolved configThe project name comes from the directory name by default. Override with -p flag or COMPOSE_PROJECT_NAME env var.
How services find each other
Compose creates a default bridge network per project. All services join it automatically. Inside that network, service names resolve via Docker's embedded DNS:
services:
web:
image: myapp
environment:
DB_HOST: db # ← just "db", not localhost or an IP
db:
image: postgres:16From inside the web container, ping db works. The hostname db resolves to the db container's IP. No localhost, no host.docker.internal, no manual networking.
v1 vs v2 (and why this matters in 2026)
docker-compose(v1) was a Python tool, separate from the Docker daemon. Hyphenated. Deprecated since July 2023.docker compose(v2) is a Go-based plugin built into modern Docker. Space, not hyphen. The current standard.
If you see docker-compose in old tutorials, the commands are nearly identical, but you should be running docker compose today. Most distros ship v2 by default; v1 is no longer maintained.
Common mistakes
Confusing down with stop
docker compose stop # stops containers; everything still exists
docker compose down # stops AND removes containers + the project network
docker compose down -v # also wipes volumes (data loss!)New users do down -v casually and lose their dev database.
Using localhost to talk between services
# WRONG: from inside `api` container, localhost is the api container itself
DATABASE_URL: postgres://postgres@localhost:5432/app
# RIGHT: use the service name
DATABASE_URL: postgres://postgres@db:5432/appEvery container has its own loopback. Service-to-service traffic goes by service name, never localhost.
Forgetting condition: service_healthy in depends_on
# WRONG: api starts when db's container starts, NOT when db is ready to accept queries
depends_on: [db]
# RIGHT: api starts only after db's healthcheck passes
depends_on:
db:
condition: service_healthyThe simple list form just orders container starts. The map form with condition actually waits for readiness — assuming the dependency has a healthcheck.
Bind-mounting node_modules on Mac/Windows
# Slow on Docker Desktop because of the cross-VM sync
volumes:
- .:/app
# Faster: keep node_modules inside a named volume, away from the bind sync
volumes:
- .:/app
- api_node_modules:/app/node_modulesClassic local-dev gotcha that turns 2-second installs into 60-second waits.
Real-world usage
- Local dev environments: the dominant use case.
git clone && docker compose upis the standard onboarding. - CI/CD test fixtures: spin up Postgres + Redis + the app's mock dependencies in one step, run integration tests, tear down.
- Single-host production: small services, side projects, internal tools. Compose +
restart: unless-stopped+ a reverse proxy is a perfectly fine stack for low-traffic apps. - Demo and tutorial repos: every tool that wants you to try it locally ships a Compose file. The default expectation now.
Follow-up questions
Q: What is the difference between docker compose up and docker compose run?
A: up starts the service as part of the running stack (long-lived, attached to network, port-mapped). run creates a new one-off container based on the same service config — typically used for one-shot tasks (docker compose run api npm test). run does NOT publish ports by default unless you add --service-ports.
Q: Can I have multiple Compose files for one project?
A: Yes: docker compose -f compose.yaml -f compose.dev.yaml up. Later files override earlier ones. Common pattern: a base compose.yaml with prod-shaped config, a compose.override.yaml (auto-loaded) or compose.dev.yaml with dev tweaks (bind mounts, exposed dev ports, hot reload).
Q: What is a Compose profile?
A: A way to mark services as opt-in. profiles: [debug] in a service means it only starts when you docker compose --profile debug up. Useful for debug containers, integration tests, or optional monitoring sidecars that you do not always want running.
Q: When is Compose not enough?
A: Multi-host deployment, automatic failover, rolling updates, autoscaling — Compose is single-host and does none of those. When you need them, move to Docker Swarm (still simpler than K8s) or Kubernetes (the industry default for multi-host).
Q: (Senior) How would you structure a Compose project for prod, staging, and dev with minimum duplication?
A: Base compose.yaml with all services and shared config (images, volumes, healthchecks). Then compose.dev.yaml (bind mounts, exposed dev tooling), compose.staging.yaml (different domains, no debug, smaller resource limits), compose.prod.yaml (resource caps, restart policies, no exposed dev ports). Use docker compose -f compose.yaml -f compose.<env>.yaml up. Anything secret stays out of YAML — use env_file pointing at environment-specific dotenv files that are not committed.
Examples
Three-service app (web + api + db)
# compose.yaml
services:
web:
image: nginx:1.27-alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on: [api]
api:
build: ./api
environment:
DATABASE_URL: postgres://postgres:devpass@db:5432/app
NODE_ENV: production
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: devpass
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:$ docker compose up -d --build # build api image, start everything
$ docker compose logs -f api # tail api logs
$ docker compose exec api sh # shell into api
$ docker compose down # stop, keep volumesDev override pattern
# compose.override.yaml (auto-loaded if present)
services:
api:
build:
target: dev # multi-stage Dockerfile with a 'dev' stage
volumes:
- ./api/src:/app/src # live source mount for hot reload
environment:
NODE_ENV: development
command: npm run dev # override prod CMD
ports:
- "9229:9229" # node debug port$ docker compose up -d # auto-merges compose.yaml + compose.override.yamlProd-shaped base file plus a dev override gives you the same services with dev affordances on top.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.
Comments
No comments yet