Skip to main content

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.yaml or legacy docker-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

yaml
# 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:
bash
$ 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 Started

One command, three services, a network, and a volume. Reproducible on any machine that has Docker.

The compose file's main blocks

yaml
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

bash
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 config

The 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:

yaml
services: web: image: myapp environment: DB_HOST: db # ← just "db", not localhost or an IP db: image: postgres:16

From 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

bash
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

yaml
# 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/app

Every container has its own loopback. Service-to-service traffic goes by service name, never localhost.

Forgetting condition: service_healthy in depends_on

yaml
# 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_healthy

The 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

yaml
# 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_modules

Classic 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 up is 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)

yaml
# 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:
bash
$ 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 volumes

Dev override pattern

yaml
# 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
bash
$ docker compose up -d # auto-merges compose.yaml + compose.override.yaml

Prod-shaped base file plus a dev override gives you the same services with dev affordances on top.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Comments

No comments yet