Docker Compose: a whole local stack in one file
Monday, 9:00: Elif starts at StayHub, and by Tuesday evening she still can't run the app. The README has 23 setup steps, and step 14 is out of date.
Your app, its database, its cache and its worker, all started with one command from one file.
- Problem
A new engineer joins the booking team. Running the app needs a web server, a background worker, PostgreSQL 17, Redis 7, a migration step and six environment variables. The README has 23 steps and is out of date.
- Why the simple system fails
Long `docker run` commands in a wiki drift, start in the wrong order (the API crashes because Postgres isn't ready), and every laptop ends up slightly different.
- Engineering concept
Describe the whole stack declaratively: services, how they're built, how they connect, what they depend on and how they know they're healthy. One command brings it to that state.
- Technology
compose.yaml services, networks, volumes, healthchecks with depends_on conditions, env files and interpolation, override files, profiles, one-off migration services, watch mode.
- Trade-offs
Fast, reproducible local and single-host environments, but no multi-host scheduling, limited rollout control and no autoscaling. Compose is not a production orchestrator for most teams.
The problem
2 min- Maya · Product managerElif started yesterday. She still hasn't seen the booking page run on her laptop.
- You · Engineer at StayHubThe README has 23 steps. Step 14 installs Redis 6; we've used Redis 7 since spring.
- Lina · Senior engineer, your mentorYou lead this one. I'll check your answers.
The big idea
4 minDescribe the whole stack in one file. One command brings every laptop to the same state.
A shell script of docker run commands says how, step by step, and breaks if something already runs. Compose describes what should exist; up converges to it from any state.
How it works
5 min- 1Reads compose.yaml
starts a set of containers from one file. Each service (
web,db) has an image or build, settings, ports, volumes.docker compose configprints the final result. - 2Creates a network and volumes
A private network, so
webreachesdb:5432andcache:6379by name.pgdatais created once and kept acrossdownandup. - 3Starts in dependency order
depends_onsays which services come first. Alone, it only means started: Postgres needs ~4 s to accept connections. - 4Waits for ready, not started
A asks "are you ready?" (
pg_isready). Withcondition: service_healthy, web waits for a ready database. - 5Runs one-off steps once
A
migrateservice runs migrations and exits; web and worker wait withcondition: service_completed_successfully. - 6Converges
Run
upagain and nothing changes unless the file did; change one service and only it is recreated.
Watch a decision being made
4 minYou write StayHub's compose.yaml; Lina checks
Replace the 23-step README: docker compose up starts web, worker, PostgreSQL 17, Redis 7 and migrations, in order, with no real secrets in git.
- 1List the pieces, wire them by name
Five services, one volume, six settings. Only web publishes a port:
127.0.0.1:3000:3000.Why: Lina: publish only what you open in a browser, or you'll clash with someone's local Postgres.
- 2Express readiness
Health checks on db and cache; web and worker depend on them with
service_healthy.yamldb: image: postgres:17 healthcheck: test: ["CMD-SHELL", "pg_isready -U app -d stayhub"] interval: 3s retries: 20 web: depends_on: migrate: { condition: service_completed_successfully }Why: Removes the half-the-time ECONNREFUSED crash without guessing. Lina: the app should still retry; production doesn't run Compose.
- 3Migrate once
A
migrateservice starts once db is healthy; everything else waits for it.Why: One run, and a failed migration stops the stack with a clear error.
- 4Keep secrets out, test like Elif
Commit
.env.example, gitignore.env, pinpostgres:17andredis:7. Fresh clone,up: about 2 minutes to a working booking page.Why: Git history is forever; pinned versions stop the Redis 6 bug. Lina: "now the README is four lines."
In real life
3 minGitHub: development in the cloud
- Has publicly described moving development of github.com to Codespaces
- A huge app; laptop setup took hours
- A prepared machine in seconds, but needs the internet and a budget
Worth it for a very large codebase with a team to maintain it.
Sentry: the whole product in Compose
- Publicly documents self-hosting as a Docker Compose setup on one machine
- Many services start with one script
- Runs anywhere, offline, for free
Worth it when one machine holds the whole stack.
What goes wrong
3 minReplicas race; a failed migration crash-loops the app.
A one-off migrate service, with service_completed_successfully.
Started isn't ready; sleeps are too short or wasteful.
Health checks and service_healthy; the app retries too.
Secrets stay in git history forever.
.env.example with dummy values; test-mode keys locally.
Trade-offs
3 min| Setup | Fits | You pay |
|---|---|---|
| Everything native | Solo projects, no dependencies | Version drift; long setup docs |
| Compose for dependencies, app native | Frontend-heavy apps on macOS | App runtime still drifts |
| Full stack in Compose | Many services, frequent onboarding, CI | Sync overhead; Docker Desktop memory |
| Compose in production, one host | Internal tools | Single point of failure; restart deploys |
- Karim · CTORun production the same way: one 16-core VM,
docker compose up -d. Cheaper than three VMs. - Omar · On-call SREOne VM is one failure from a full outage. Today, if app-2 dies, app-1 and app-3 keep taking bookings.
- Karim · CTOVMs rarely die. And we'd save about a third of the server bill.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- What the local stack contains, so a new engineer is productive in minutes, not 2 days.
- How readiness is expressed: health checks plus conditions, and the app still retries.
- Whether Compose runs production: not for StayHub's booking site.
Write a compose.yaml for StayHub's local stack: web (our Dockerfile), worker (same image, different command), PostgreSQL 17 with a named volume, Redis 7, a one-off migrate service, Mailpit under a tools profile. db and cache get healthchecks; migrate starts when db is healthy; web and worker start after migrate completes successfully. Publish only web's port, on localhost. The 6 env variables go in a committed .env.example with fake values; real ones in an uncommitted .env. Then run down -v and up ten times and show web started cleanly every time.
- Ten cold starts: web starts every time, not "usually".
depends_onuses conditions, not just a list of names.- Only web has
ports:, bound to127.0.0.1;.envis gitignored.
- It "fixes" the race with
restart: alwaysorsleep 10. - It publishes the database or Redis ports "for convenience".
- It suggests production on one large VM with
docker compose up.
Check yourself
2 mindocker compose up ends with web crashing: ECONNREFUSED db:5432. A second up usually works. What's the fix?services:
web:
build: .
depends_on: [db]
db:
image: postgres:17Next module: StayHub runs 40 containers on 8 servers. At 3:12 a.m. one server dies, and its containers stay dead until Omar wakes up.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
You lead the worked example. Lina's comments are in the "why" lines where she pushed back.
You write StayHub's compose.yaml
Replace the 23-step README with one file, so docker compose up starts web, worker, PostgreSQL 17, Redis 7 and the migrations, in the right order, with no real secrets in git.
- 1List the pieces
Five services (web, worker, db, cache, migrate), one volume (
pgdata), six settings (database URL, Redis URL, SMTP URL, payment test key, maps key, session secret).Why: Every line of the old README maps to one of these. If something isn't on the list, it isn't in the stack.
- 2Wire them by name
web and worker use
db:5432andcache:6379. Only web publishes a port, on loopback:127.0.0.1:3000:3000.Why: Compose creates a private network, so services find each other by name. Lina: "publish only what you open in a browser, or you'll clash with someone's local Postgres."
- 3Express readiness
Health checks on db (
pg_isready) and cache (redis-cli ping); web and worker depend on them withcondition: service_healthy.yamldb: image: postgres:17 healthcheck: test: ["CMD-SHELL", "pg_isready -U app -d stayhub"] interval: 3s retries: 20Why: Started isn't ready. This removes the half-the-time ECONNREFUSED crash without guessing a sleep time.
- 4Migrate once
A
migrateservice runsnpm run db:migrateand exits. web and worker depend on it withcondition: service_completed_successfully.Why: Only one thing migrates, and a failed migration stops the stack with a clear error instead of a crash loop.
- 5Keep secrets out
Commit
.env.examplewith names and dummy values; gitignore.env; developers use payment test keys. Pinpostgres:17andredis:7to match production.Why: Anything in git stays in its history forever. Pinned versions stop the "Redis 6 on one laptop" bug from coming back.
- 6Test it like Elif
Clone the repo into a fresh folder,
cp .env.example .env,docker compose up. Time it: about 2 minutes to a working booking page. Thendocker compose down && docker compose upand check the test bookings are still there.Why: The only real test of onboarding is a clean start. Lina: "good, and now the README is four lines."
cp .env.example .env # once; fill in personal test keys
docker compose up -d # start the stack in the background
docker compose watch # sync code changes into web as you edit
docker compose logs -f web worker # follow two services
docker compose exec db psql -U app stayhub
docker compose run --rm web npm test # one-off command in a fresh container
docker compose --profile tools up -d # include Mailpit
docker compose config # print the fully resolved file
docker compose down # remove containers + network, keep volumes
docker compose down -v # ...and delete volumes: a fresh database| Mechanism | What it's for | Gotcha |
|---|---|---|
.env next to compose.yaml | Interpolating ${VAR} inside compose.yaml | Not automatically passed into containers |
env_file: | Loading variables into a container's environment | Values are visible via docker inspect; fine for local test keys, not production secrets |
environment: | Explicit per-service values; overrides env_file | Don't commit real secrets here |
| compose.override.yaml | Auto-merged personal or local tweaks | Gitignore it if it's personal, or everyone inherits your ports |
-f compose.yaml -f compose.ci.yaml | Explicit layering for CI or other environments | Later files override earlier ones key by key |
Interactive simulation
Visual diagram
The booking app's local stack as Compose sees it. Click components or start the walkthrough.
Analogy
- Opening a restaurantTelling each employee their job every morningDocker ComposeTyping a docker run per container
- Opening a restaurantThe posted opening checklistDocker ComposeThe compose.yaml file
- Opening a restaurantWaiters serve once the kitchen says readyDocker ComposeWaiting for health checks
- Opening a restaurantStaff calling "kitchen!" by nameDocker ComposeServices reaching each other by name
- Opening a restaurantA regional manager for fifty branchesDocker ComposeAn orchestrator like Kubernetes
Where the analogy breaks: a checklist can't restart a chef who faints. Compose can restart a crashed container, but if the whole machine goes down, everything goes down with it.
Check your understanding
.env contains POSTGRES_VERSION=16 and STRIPE_KEY=sk_test_123. compose.yaml has image: postgres:${POSTGRES_VERSION:-17} for db, and web has no env_file or environment entry for STRIPE_KEY.
Practice ladder
Trace a cold `docker compose up`
Scenario Warm-upWith your compose.yaml (db and cache have health checks; migrate depends on db healthy; web and worker depend on migrate completing successfully and cache healthy), Elif runs docker compose up on a fresh laptop.
- List the order in which things are created and started.
- Postgres takes 4 s to become healthy and the migration takes 6 s. Roughly when does web start?
- Then she runs
docker compose downanddocker compose upagain. Are her test bookings still there?
Finish the worker service
Code PracticeLina leaves the worker service for you. It uses the same image as web, runs npm run worker, needs the database and Redis, must not start before migrations are done, and sends emails through Mailpit (smtp://mailpit:1025), which is in the tools profile.
worker:
build: .
command: ___
environment:
DATABASE_URL: ___
REDIS_URL: ___
SMTP_URL: smtp://mailpit:1025
depends_on:
___Fill the gaps. Then answer: should worker publish a port?
Fix the search team's compose.yaml
Debugging PracticeNew engineers on the search team say their stack "works sometimes". Find at least five problems and rewrite the file.
services:
search-api:
build: .
ports: ["3001:3001"]
environment:
DATABASE_URL: postgres://search:s3cr3t-prod-pass@localhost:5432/search
OPENSEARCH_URL: http://opensearch:9200
depends_on: [db, opensearch]
command: sh -c "sleep 15 && npm run migrate && npm start"
db:
image: postgres:latest
ports: ["5432:5432"]
environment: { POSTGRES_PASSWORD: s3cr3t-prod-pass }
opensearch:
image: opensearchproject/opensearch:2
ports: ["9200:9200"]Compose in production for the owner portal?
Decision StretchThe owner portal (900 hotel owners, used during office hours) still runs on owners-1 with plain docker run commands. Karim proposes moving it to docker compose up -d on that same VM. The facts:
- Owners tolerate an hour of downtime on a rare bad day; guests never use the portal.
- Deploys happen twice a week and take about 20 s of restart.
- The database is now managed Postgres; photos are in object storage.
- The main booking site will move to a proper orchestration platform next quarter.
Write a short decision: "We chose X over Y because…, accepting the risk of…".
Explain it yourself
A new teammate asks why the project's compose.yaml has healthchecks, a migrate service and profiles, and why production doesn't just run docker compose up on a server. Explain.