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 config before 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 web reaches the database simply by connecting to the hostname db. 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 down followed by up would hand you an empty database every time.
  • depends_on with conditions controls startup ordering. Plain depends_on only waits for the dependency container to start; condition: service_healthy waits 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 run and 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
$ docker compose version
Docker Compose version v2.29.2

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

01
Write the compose file

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.

02
Validate the file before anything runs

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"
--quiet validates without printing; drop it to see the resolved file.
$ docker compose config | head -20
name: compose-lab services: cache: healthcheck: test: - CMD - redis-cli - ping image: redis:7-alpine networks: backend: null restart: unless-stopped db: environment: POSTGRES_DB: appdb POSTGRES_PASSWORD: secret

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.

03
Bring the stack up
docker compose up -d
Creates the network, volumes, and starts services in dependency order.

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

$ docker compose up -d
[+] Running 7/7 ✔ Network compose-lab_frontend Created ✔ Network compose-lab_backend Created ✔ Volume "compose-lab_db-data" Created ✔ Container compose-lab-db-1 Healthy ✔ Container compose-lab-cache-1 Healthy ✔ Container compose-lab-web-1 Started

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.

04
Check status and logs
$ docker compose ps
NAME SERVICE STATUS PORTS proj-db db Up (healthy) proj-web web Up 0.0.0.0:8080->80/tcp

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)
$ docker compose logs db | tail -3
db-1 | 2026-09-21 14:02:11.203 UTC [1] LOG: database system is ready to accept connections

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.

05
Prove the services can reach each other

"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
$ docker compose exec web getent hosts db
172.20.0.3 db
$ docker compose exec db pg_isready -U postgres
/var/run/postgresql:5432 - accepting connections
$ docker compose exec cache redis-cli ping
PONG

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:

$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080
200
$ nc -z -w 2 localhost 5432; echo "exit=$?"
exit=1

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.

06
Manage the lifecycle and tear down

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:

$ docker compose exec db psql -U postgres -d appdb -c "CREATE TABLE IF NOT EXISTS lab_check (id int); INSERT INTO lab_check VALUES (1);"
INSERT 0 1
$ docker compose down && docker compose up -d
[+] Running 4/4 ... Container compose-lab-web-1 Started
$ docker compose exec db psql -U postgres -d appdb -c "SELECT * FROM lab_check;"
id ---- 1 (1 row)

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

You 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), run docker compose up -d --force-recreate, and watch web never start: docker compose ps will show db as unhealthy and web absent. 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 -d from 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.yaml should find only the ${DB_PASSWORD} reference, never a literal value. Anyone who clones the repo gets the structure; only people with the .env get the secret.
$ docker compose ps --format "table {{.Name}}\t{{.Status}}"
NAME STATUS compose-lab-cache-1 Up 5 minutes (healthy) compose-lab-db-1 Up 5 minutes (healthy) compose-lab-web-1 Up 5 minutes

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 -v

down -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 on web does nothing for ordering. Confirm with docker inspect compose-lab-db-1 | grep -A5 Healthcheck.
  • Old containers conflict: run docker compose down before re-creating. A harder variant is the error container name "/compose-lab-db-1" is already in use from a container created outside Compose (e.g. a manual docker run --name); Compose will not adopt it, so remove it with docker rm -f compose-lab-db-1.
  • Env not applied: confirm a .env file or environment keys are present. The file must be named exactly .env and sit in the directory you run the command from, not next to a compose file referenced with -f ../other/compose.yaml. Diagnose with docker 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 changed POSTGRES_USER: pg_isready -U postgres fails if the superuser is not literally postgres. Match the probe's -U flag 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 (or ss -tlnp | grep 8080 on 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.

SymptomLikely causeFix
Port is already allocatedAnother service or a previous stack holds the host portChange the host-side port in the compose file or stop the conflicting stack.
Web crashes on boot talking to the databasedepends_on ordered startup but the DB was not readyAdd a healthcheck and use condition: service_healthy.
Permission denied on a mounted volumeHost UID does not match the container userSet user: in the service or fix ownership on the host path.
Changes to the file seem ignoredCompose reuses existing containers; it does not recreate on every config tweakRun docker compose up --force-recreate after structural changes.
Data vanished after down--volumes was passed, deleting named volumes tooOmit 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.yaml and a compose.dev.yaml with bind-mounted source and debug ports, then run docker 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, so docker compose up --build compiles 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 config before deploying anything.
  • Proved DNS-based service discovery and network segmentation between tiers.
  • Demonstrated data persistence across a full down/up cycle with a named volume.

Key Takeaways

  • Declare the end state, let Compose converge to it: the file describes services, networks, and volumes; up creates only what is missing and recreates only what changed.
  • Healthy beats started: plain depends_on waits for a container to exist; condition: service_healthy waits 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 db and cache to 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 db off the frontend network means the database is unreachable from anywhere except the tiers that need it.
  • Volumes outlive containers by design: down keeps named volumes, down -v deletes 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 config resolves 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.