Docker Compose: Bring Up a Full Stack with One Command
You will describe a web + database + cache stack in a single Compose file and launch it with one command. By the end you will have reproducible, ordered startup with health checks instead of fragile manual docker run sequences.
Running containers by hand works until the app needs more than one of them. The moment you have a web tier, a database, and a cache, a chain of docker run commands becomes a shell script that is easy to get wrong: networks must exist first, startup order matters, passwords get typed on the command line, and nobody remembers which flags the database container needed. Docker Compose replaces all of that with one declarative YAML file that describes the desired end state, services, networks, volumes, and the order they must come up in, and a single docker compose up that converges reality to that description.
This lab builds that file from scratch, validates it before anything runs, brings the stack up, proves the services can actually reach each other, and then walks through the full lifecycle commands you will use daily. Every claim is verified with a command and its expected output, so you can follow it end to end on any machine with Docker installed.
Learning Objectives
- Define services, networks, and volumes declaratively.
- Order startup with depends_on and healthchecks.
- Manage the stack lifecycle with compose commands.
- Validate a Compose file with
docker compose configbefore deploying it. - Verify service-to-service connectivity and DNS-based discovery by name.
- Time: ~3 hours · Difficulty: Intermediate · Prereqs: Docker Compose v2.
Architecture Overview
Four Compose concepts do all the work in this file, and it is worth being precise about what each one creates before writing any YAML:
- Services are container definitions: an image, its environment, ports, and restart policy. Compose names the containers it creates
<project>-<service>-1, where the project name defaults to the directory the file lives in. - Networks are isolated bridges Compose creates per stack. Every service on a network can resolve the others by service name via Docker's embedded DNS, that is how
webreaches the database simply by connecting to the hostnamedb. Services not on a shared network cannot see each other at all, which is the segmentation primitive you will use to keep the database off the frontend network. - Volumes are named, managed storage that survives container replacement. Without one,
docker compose downfollowed byupwould hand you an empty database every time. - depends_on with conditions controls startup ordering. Plain
depends_ononly waits for the dependency container to start;condition: service_healthywaits until its healthcheck passes, which is the difference between "Postgres process exists" and "Postgres is accepting connections".
Under the hood, Compose is a reconciler: every time you run docker compose up it compares the file (the desired state) against what is actually running (the observed state) and creates, recreates, or removes only what differs. Nothing changed since the last run? It does nothing. You edited one service's environment? Only that service is recreated. This idempotent, converge-toward-the-file behaviour is the same control-loop idea Kubernetes uses at cluster scale, which is why Compose is such a good stepping stone toward it.
Environment Setup
Prerequisites
- Docker Engine 24+ with Compose v2 (
docker compose version). - A sample app with at least two services (web plus database), or the lab's example files.
- Assumed skills: basic
docker runand YAML indentation.
You will need: Docker Compose v2 (docker compose version).
Compose v2 ships as a Docker CLI plugin, the command is docker compose (space), not the legacy Python binary docker-compose (hyphen). Confirm it before going further; anything below v2.20 lacks some of the syntax used here:
Verify the Compose plugin
docker compose version
Before you begin: create a project directory for the compose file. The directory name matters because it becomes the default project name, which prefixes every container, network, and volume the stack creates. Using a dedicated, descriptive directory keeps stacks from colliding:
Create the project directory
mkdir compose-lab && cd compose-lab
Compose looks for its file in this order: compose.yaml, compose.yml, then the legacy docker-compose.yaml. This lab uses compose.yaml, the current canonical name. If your file has a different name, every command below needs -f <filename>.
Platform notes: on macOS and Windows, Docker Desktop bundles the Compose plugin already. On a bare Linux server, install the docker-compose-plugin package from Docker's apt/dnf repository rather than the standalone binary. And a habit worth forming early: run Compose commands from the project directory so the project name, .env discovery, and relative build contexts all resolve the way the file expects.
Step-by-Step Execution
Declaring services with a healthcheck and depends_on guarantees the DB is ready before the web app starts.
# compose.yaml
services:
db:
image: postgres:16
environment: { POSTGRES_PASSWORD: secret }
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
web:
image: nginx:stable
depends_on:
db: { condition: service_healthy }
ports: ["8080:80"]
Expected output:
$ docker compose config --quiet && echo "compose file is valid"
compose file is valid
That minimal file works, but a stack you would actually keep needs more: a named volume so the data survives teardowns, a dedicated network so the database is not exposed to anything but the services that need it, and the cache tier the architecture diagram promised. Here is the full version, with each addition annotated:
compose.yaml, full lab stack
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD} # read from .env, not hardcoded
POSTGRES_DB: appdb
volumes:
- db-data:/var/lib/postgresql/data # survives down/up cycles
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
networks: [backend] # NOT on the frontend network
restart: unless-stopped
cache:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
networks: [backend]
web:
image: nginx:stable
depends_on:
db:
condition: service_healthy # wait for pg_isready, not just start
cache:
condition: service_healthy
ports:
- "8080:80" # host:container
networks: [frontend, backend] # web bridges both tiers
networks:
frontend:
backend:
volumes:
db-data:
Two details deserve attention. First, the healthcheck runs inside the container: pg_isready -U postgres asks the local Postgres whether it accepts connections and exits 0 only when it does, and redis-cli ping returns PONG. Without timeout and retries, a single slow probe can leave a service flapping between healthy and unhealthy. Second, web sits on both networks while db and cache sit only on backend, so the only path to the database from outside the stack is through the web tier.
.env, variables interpolated into compose.yaml
DB_PASSWORD=secret
Compose reads .env from the project directory automatically and substitutes ${DB_PASSWORD}. Never commit this file; add it to .gitignore and ship a .env.example with placeholder values instead.
Common mistake: YAML is whitespace-sensitive. A single tab character or a misaligned depends_on key produces a parse error, or worse, silently nests a key one level too deep where Compose ignores it. Verify indentation is consistent two-space before continuing, the next step catches what your eyes miss.
What this teaches: The compose file is the whole stack as data; services, networks, and volumes declared once, reused everywhere.
docker compose config parses the file, resolves variable interpolation, merges any override files, and prints the effective configuration, what Compose will actually act on. Running it before up turns cryptic runtime failures into clean syntax errors, and it is the fastest way to confirm a variable from .env actually landed where you expected.
docker compose config --quiet && echo "config OK"Note what happened in that output: ${DB_PASSWORD} became secret, and the project name compose-lab was derived from the directory. If the password line still showed a literal ${DB_PASSWORD}, the .env file is missing, misspelled, or in the wrong directory, fix that now, not after the database has initialized itself with an empty password.
How to verify: docker compose config --services should print exactly db, cache, and web, one per line. If one is missing, it failed to parse as a service, usually an indentation problem under services:.
Expected output:
$ docker compose config --services
web
db
What this teaches: Validation before running catches YAML and schema errors while they are still cheap; never debug a running stack for a typo.
docker compose up -dThe -d flag detaches: containers run in the background and your terminal returns. Without it, Compose streams the aggregated logs of every service to your terminal and tears the stack down when you press Ctrl+C, useful for debugging, annoying for a lab.
Read the output bottom-up and you can see the dependency engine working: networks and the volume are created first, db and cache report Healthy (Compose blocked on their healthchecks), and only then is web started. If you re-run docker compose up -d after editing the file, Compose recreates only the services whose definition changed and leaves the rest untouched.
Common mistake: pulling images on a slow connection makes the first up look hung. It is not, Compose is downloading postgres:16 (~240MB). You can pre-pull with docker compose pull to separate download time from startup time.
For scripts and CI pipelines, prefer docker compose up -d --wait: instead of returning as soon as containers are started, it blocks until every service with a healthcheck reports healthy (and exits non-zero on timeout). That turns "is the stack ready?" from a race-prone sleep 10 into a deterministic gate.
Expected output:
$ docker compose up -d
[+] Running 4/4
Network compose-lab_default Created
Container compose-lab-db-1 Started
Container compose-lab-web-1 Started
What this teaches: Up is reconciliation: compose creates what is missing and leaves the rest, which makes repeated runs safe.
docker compose ps is scoped to this stack only, unlike docker ps, it will not show unrelated containers on the host. The (healthy) marker next to db and cache comes straight from the healthchecks; if a service shows (unhealthy), its probe is failing and anything with condition: service_healthy will never start.
Follow the logs of one service
docker compose logs -f db
Expected output:
$ docker compose ps
NAME IMAGE STATUS
compose-lab-web-1 nginx:1.27 Up 2 minutes
compose-lab-db-1 postgres:16 Up 2 minutes (healthy)
Omit the service name (docker compose logs -f) to follow all services at once, interleaved and colour-coded by service, this is the single most useful debugging view Compose gives you. Add --tail 50 to skip the startup noise, and --since 10m to bound the window when a stack has been running for days.
How to verify: docker compose top shows the actual processes inside each container. The db service should list a postgres process tree; if the container is Up but top shows nothing, the entrypoint crashed and restarted, check docker compose logs db for the reason.
What this teaches: Status and logs are the observability layer; ps shows desired state, logs show actual behavior.
"Up" only means the processes are running. The reason Compose exists is service discovery: each service resolves the others by name through Docker's embedded DNS. Prove it by opening a shell inside the web container and resolving db by name, then exercising each backend with its native client.
Name resolution and port reachability from inside the stack
docker compose exec web getent hosts db
docker compose exec db pg_isready -U postgres
docker compose exec cache redis-cli ping
Expected output:
$ docker compose exec web getent hosts db
172.20.0.3 db
The first command is the important one: the bare hostname db resolved to a container IP on the backend network. That IP changes every time the container is recreated, which is exactly why application configs should reference service names and never addresses. The other two confirm each backend answers on its protocol, not just that its container exists.
Also confirm the segmentation you declared: db has no published port, so it is unreachable from the host, while web is published on 8080:
Common mistake: docker compose exec targets the container by service name (web), not container name (compose-lab-web-1), and it fails with no container found if the service is stopped. Use docker compose run --rm web sh instead when you need a shell in a service that is not currently running.
What this teaches: Service-to-service reachability over the project network is the integration test; DNS names are the service names.
Compose distinguishes four states you will move between constantly. stop halts containers but keeps them (and the data on their volumes); start resumes them; restart is a stop/start pair; down removes containers and networks but keeps named volumes; and down -v removes the volumes too, the full reset.
The lifecycle commands
docker compose stop # halt, keep everything
docker compose start # resume halted containers
docker compose restart web # bounce one service
docker compose down # remove containers + networks, keep volumes
docker compose down -v # also delete volumes: data is gone
Expected output:
$ docker compose down
[+] Running 3/3
Container compose-lab-web-1 Removed
Container compose-lab-db-1 Removed
Network compose-lab_default Removed
Prove the volume did its job: stop the stack, bring it down, start it again, and check that the database still holds data written before the teardown:
The row survived a full container replacement because it lives in the compose-lab_db-data volume, not in the container's writable layer. When you are finished with the lab entirely, docker compose down -v returns the host to a clean state, this is the command to run before resetting the lab for another attempt.
How to verify: after down -v, docker volume ls | grep compose-lab should print nothing, and docker network ls | grep compose-lab should be empty too. Leftovers mean a container from the project is still running somewhere, find it with docker compose ps -a.
What this teaches: Lifecycle commands are the discipline: down without --volumes keeps data, with it starts truly fresh.
Step Flow Map
Each box is one step in the lab; work them in order, since later steps build on earlier state.
Progress So Far
How It Works
A compose file is desired state for a whole stack: services, networks, and volumes declared in one YAML document. docker compose up reconciles reality toward that document: it creates the project network, starts dependencies first, injects environment, mounts volumes, and attaches everything. The project name (from the directory or -p) namespaces every resource, which is why two copies of the same file can run side by side without colliding.
depends_on orders startup, but ordering is not readiness: a database container can be started minutes before it accepts connections. Healthchecks close that gap; depends_on with condition: service_healthy makes the web service wait until the database actually answers. This distinction causes most compose flakiness in the wild.
Compose owns the full lifecycle, which makes it ideal for development and for this lab's teardown discipline. down stops and removes containers and networks; adding --volumes also removes named volumes, which is the difference between restart fresh and keep my data. Knowing which one you invoked is how you avoid deleting a database you meant to keep.
Testing & Validation
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080You should see 200. If so, the stack answers on its published port and the compose file is valid.
That is the minimal acceptance test. For a lab you actually trust, validate three more properties before calling it done:
- Healthchecks genuinely gate startup. Deliberately break the db healthcheck (change the probe to
pg_isready -U nosuchuser), rundocker compose up -d --force-recreate, and watchwebnever start:docker compose pswill showdbasunhealthyandwebabsent. Revert the probe and confirm the stack recovers. This proves the ordering is real, not decorative. - The stack is deterministic.
docker compose down -v && docker compose up -dfrom a clean slate should reach all-healthy with no manual intervention. If it only works because of state left over from previous runs, it is not reproducible, and that is precisely the failure mode Compose exists to eliminate. - No credentials in the file.
grep -i password compose.yamlshould find only the${DB_PASSWORD}reference, never a literal value. Anyone who clones the repo gets the structure; only people with the.envget the secret.
Three services, two healthy backends, one published port, and the entire thing described by a file short enough to read in one screen, that is the deliverable.
Every check above has passed, so tear the lab stack down last. Nothing validates after this point by design:
docker compose down -vdown -v removes the containers, networks, and the named volume, leaving no lab residue behind.
Troubleshooting
The issues below are the ones that actually bite in this lab, with the cause and the fix:
- web starts before db is ready: use a healthcheck with
condition: service_healthy. If you already have both and it still happens, check that the healthcheck is defined on the dependency (db), not on the dependent, putting the probe onwebdoes nothing for ordering. Confirm withdocker inspect compose-lab-db-1 | grep -A5 Healthcheck. - Old containers conflict: run
docker compose downbefore re-creating. A harder variant is the errorcontainer name "/compose-lab-db-1" is already in usefrom a container created outside Compose (e.g. a manualdocker run --name); Compose will not adopt it, so remove it withdocker rm -f compose-lab-db-1. - Env not applied: confirm a
.envfile or environment keys are present. The file must be named exactly.envand sit in the directory you run the command from, not next to a compose file referenced with-f ../other/compose.yaml. Diagnose withdocker compose config: if the output shows an empty value or a literal${VAR}, interpolation never happened. - db is (unhealthy) and web never starts: the probe itself is failing, not the ordering. Run the healthcheck command by hand,
docker compose exec db pg_isready -U postgres, and read its error. The classic cause is a changedPOSTGRES_USER:pg_isready -U postgresfails if the superuser is not literallypostgres. Match the probe's-Uflag to whatever user the container initializes. - port 8080 already allocated: something on the host already owns the published port. Find it with
lsof -i :8080(orss -tlnp | grep 8080on Linux), then either stop the offender or change the host side of the mapping to"8081:80", the container port stays 80, only the host port moves.
Quick-reference for the failures you are most likely to hit in this lab.
| Symptom | Likely cause | Fix |
|---|---|---|
| Port is already allocated | Another service or a previous stack holds the host port | Change the host-side port in the compose file or stop the conflicting stack. |
| Web crashes on boot talking to the database | depends_on ordered startup but the DB was not ready | Add a healthcheck and use condition: service_healthy. |
| Permission denied on a mounted volume | Host UID does not match the container user | Set user: in the service or fix ownership on the host path. |
| Changes to the file seem ignored | Compose reuses existing containers; it does not recreate on every config tweak | Run docker compose up --force-recreate after structural changes. |
Data vanished after down | --volumes was passed, deleting named volumes too | Omit the flag to keep data; volumes survive a plain down by design. |
Extension Ideas
- Split prod/dev with compose override files: keep a base
compose.yamland acompose.dev.yamlwith bind-mounted source and debug ports, then rundocker compose -f compose.yaml -f compose.dev.yaml up, later files override earlier ones key by key. - Replace the nginx placeholder with a real app by adding a
build:section pointing at a Dockerfile, sodocker compose up --buildcompiles and runs your code in one step. - Add resource limits (
mem_limit,cpus) to each service and watch a memory-hungry container get OOM-killed instead of starving its neighbours. - Promote the stack to Kubernetes in Multi-Container Applications, the service/network/volume vocabulary maps almost one-to-one onto Deployments, Services, and PVCs.
- Add the build + deploy to CI/CD.
Key Results
- Launched a 3-service stack with a single command.
- Guaranteed startup order with healthchecks and depends_on.
- Made the environment reproducible from one declarative file.
- Verified the app and a clean teardown.
- Validated the file with
docker compose configbefore deploying anything. - Proved DNS-based service discovery and network segmentation between tiers.
- Demonstrated data persistence across a full
down/upcycle with a named volume.
Key Takeaways
- Declare the end state, let Compose converge to it: the file describes services, networks, and volumes;
upcreates only what is missing and recreates only what changed. - Healthy beats started: plain
depends_onwaits for a container to exist;condition: service_healthywaits for it to actually accept work. Ordered startup without healthchecks is ordering in name only. - Service names are the API: Docker's embedded DNS resolves
dbandcacheto whatever IPs the containers have today. Configs that reference names survive recreation; configs that reference IPs do not. - Networks are your segmentation boundary: a service only sees the networks you put it on. Keeping
dboff the frontend network means the database is unreachable from anywhere except the tiers that need it. - Volumes outlive containers by design:
downkeeps named volumes,down -vdeletes them. Know which one you are running before you run it, one is a restart, the other is data loss. - Validate before you deploy:
docker compose configresolves variables and prints the effective file. Thirty seconds of validation beats debugging a misinterpolated password at runtime.
Practice Challenges
Challenge 1: Gate on health, not startup
Goal: Add a healthcheck to the database and make the web service wait for service_healthy.
Hint: Break the DB password afterwards and watch the web service refuse to start; that is the gate working.
Challenge 2: Prove volume persistence
Goal: Write data to the database, run down and up, and show the data survived.
Hint: Then run down --volumes once in a scratch project to feel the difference.
Challenge 3: Override without editing
Goal: Add a compose.override.yml that changes only the published ports.
Hint: Compose merges the files automatically; the base file stays pristine.