Orchestrate Services with Docker Compose
Stop juggling containers by hand. Declare a whole multi-service stack — a web app plus a PostgreSQL database, with a private network and a named volume — in a single docker-compose.yml, then bring it up, operate it and tear it down with the modern `docker compose` v2 commands.
Problem
A real application is never a single container. You have a web app, a database, maybe a cache and a background worker — and they all have to start in the right order, find each other on a network, and keep their data across restarts. Wiring that by hand with `docker run` is painful and error-prone: you repeat long flag strings, invent a network with `docker network create`, remember which container must start first, and reconstruct it all from memory every single time. **Docker Compose** replaces that fragility with one declarative file. A `docker-compose.yml` describes every service, network and volume of your stack. One command — `docker compose up` — creates all of it in the correct order; one command — `docker compose down` — cleans it up. The definition lives in your repo, versioned alongside the code, so a teammate reproduces your entire environment with a single command. In this lab you'll write a real `docker-compose.yml` for a two-service stack (a web server and a PostgreSQL database), bring it up, watch Compose build the network and volumes for you, operate the full lifecycle, and prove the two services talk to each other over Compose's internal DNS — using the modern `docker compose` (v2) syntax throughout.
Objectives
- Explain the problem Compose solves and how a
docker-compose.ymldeclares services, networks and volumes in one place - Read and write the core Compose keys:
services,image/build,ports,environment,volumes,depends_on,networks - Author a real two-service stack (a
webservice and adbPostgreSQL service) with a named volume and a private network - Bring the stack up with
docker compose up -dand understand what Compose creates and in what order - Operate the lifecycle with
docker compose ps,logs -f,exec,stop/startanddown, and reach one service from another by its service name (Compose DNS) - Externalize configuration with environment variables and a
.envfile, validate withdocker compose config, and scale a service with--scale
Prerequisites
- Docker installed and running, with Compose v2. Run
docker compose version— if it prints a v2.x version, you're set. (Compose v2 ships inside Docker Desktop and as thedocker-compose-pluginon Linux; it's thedocker composesubcommand, with a space.) - Comfort with the single-container basics from the earlier Docker labs:
docker run,ps,logs,exec, images vs containers - A terminal (PowerShell, bash or zsh) and a web browser
- Git installed, to submit your deliverable at the end
- Internet access to pull the
nginxandpostgresimages from Docker Hub
What you will build
You'll write one file — docker-compose.yml — that declares a small but complete stack: a
web service (nginx) and a db service (PostgreSQL), connected on a private network, with a
named volume so the database keeps its data across restarts. Then you'll drive the whole thing
with a handful of docker compose commands: bring it up in the background, inspect it, read its
logs, open a psql shell in the database, prove the web container can resolve db by name, and
finally tear it down cleanly.
Why Compose exists
In the earlier labs you ran single containers with docker run. That's fine for one thing, but a
real app is several things at once. Compare the two approaches for the exact same stack:
| By hand | With Compose |
|---|---|
docker network create appnet |
networks: block (auto-created) |
docker volume create dbdata |
volumes: block (auto-created) |
one long docker run … per service, in the right order, from memory |
docker compose up -d |
| tear down each container, network and volume one by one | docker compose down |
Compose turns a fragile sequence of imperative commands into a single declarative file you
commit to your repo. You describe the desired end state; Compose figures out how to get there.
The docker compose (v2) command — mind the space
Modern Compose is a plugin built into the Docker CLI, invoked as docker compose (two words,
a space). The old standalone docker-compose (with a hyphen) is v1 and is deprecated. Everything
in this lab uses the v2 form.
One more thing you'll see in old tutorials: a version: "3.8" line at the top of the file. In
Compose v2 that top-level version key is obsolete and ignored — you can (and should) simply
omit it. If you see it in an example, it's a v1-era leftover, not something you need.
The Compose file at a glance
Every Compose file is a map with a few top-level keys:
-
services:— the containers that make up your app; each service names animage:or abuild: -
networks:— private networks your services share (Compose makes a default one even if you don't) -
volumes:— named volumes for data that must outlive a container
Inside a service you'll use keys like image, build, ports, environment, env_file,
volumes, depends_on and networks. You'll meet each of them by writing them, not just reading
about them.
How to work through this lab
Do the steps in order — each builds on the file from the last. Run every command yourself and read
its real output. You'll collect the outputs you need for the submission in Step 6.
Steps
-
The problem Compose solves and the file's anatomy
Before writing anything, get clear on why Compose exists and what a Compose file is made of.
Confirm you have Compose v2
docker compose versionYou should see a
Docker Compose version v2.x.yline. If insteaddocker composeis "not a
docker command," you're on an old setup — install/enable the Compose v2 plugin (it's bundled
with current Docker Desktop). Note the space indocker compose: that's the modern v2 CLI. The
hyphenateddocker-composeis the deprecated v1 standalone.Why not just
docker runeverything?Imagine standing up a web app plus a database by hand. You'd have to:
-
docker network create appnetso they can talk, -
docker volume create dbdataso the database survives restarts, -
docker runthe database with the network, the volume and a pile of-eenv flags, -
docker runthe web app with the same network — after the database exists, - and later stop and remove each container, the network and the volume, one by one.
That's a lot of imperative steps to remember and re-type, and nothing records the intent. Miss
one flag and you've got a subtly broken environment.What Compose gives you instead
A single
docker-compose.ymldeclares the whole stack. Its top-level shape is:services: # the containers of your app web: image: nginx:1.27-alpine ports: - "8080:80" depends_on: - db db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: secret networks: # private networks (optional; a default is created) # ... volumes: # named volumes for persistent data # ...The keys you'll use most inside a service:
Key What it does imagethe image to run (from Docker Hub or a registry) buildbuild an image from a local Dockerfileinstead of pulling oneportspublish container ports to the host, "HOST:CONTAINER"environmentset environment variables inside the container env_fileload variables from an external file (e.g. .env)volumesmount a named volume or a bind mount for persistent/shared data depends_onstart this service only after the ones it lists networkswhich private network(s) the service joins Checkpoint
- What three things can a Compose file declare at the top level? (services, networks, volumes.)
- Which command replaces "create network + create volume + several docker runs in order"? (
docker compose up.) - Is the top-level
version:key required in Compose v2? (No — it's obsolete and ignored; omit it.) - What's the difference between
docker composeanddocker-compose? (The former is v2, a CLI plugin; the latter is the deprecated v1 standalone.)
-
-
Write a real docker-compose.yml: web + db
Now write the file. Create a fresh folder and a
docker-compose.ymlinside it.mkdir compose-lab cd compose-labThe file
Put this in
docker-compose.yml. Read the comments — every block earns its place.# No top-level `version:` — it's obsolete in Compose v2 and safely omitted. services: web: # first service: a web server image: nginx:1.27-alpine # pinned tag, small Alpine base ports: - "8080:80" # HOST:CONTAINER — reach nginx at http://localhost:8080 depends_on: - db # start db before web networks: - appnet # join the private network db: # second service: PostgreSQL image: postgres:16-alpine environment: POSTGRES_PASSWORD: secret # required by the postgres image POSTGRES_USER: appuser POSTGRES_DB: appdb volumes: - dbdata:/var/lib/postgresql/data # named volume -> data survives restarts networks: - appnet networks: appnet: # a private bridge network for the stack driver: bridge volumes: dbdata: # named volume, managed by DockerRead it block by block
-
services:holds two services,webanddb. Each name becomes the container's
hostname on the network — that matters in Step 4. -
webpullsnginx:1.27-alpine, publishes8080:80, anddepends_on: [db]so Compose
startsdbfirst. It joinsappnet. -
dbpullspostgres:16-alpine. The postgres image requiresPOSTGRES_PASSWORD;
POSTGRES_USERandPOSTGRES_DBare optional and seed a user and database on first boot. -
dbdata:/var/lib/postgresql/datamounts a named volume at Postgres's data directory, so
the database's files live in a Docker-managed volume, not the container's throwaway layer. -
networks: appnetdefines an isolated bridge network. Both services join it, so they can
reach each other — and nothing outside the stack can. -
volumes: dbdatadeclares the named volume Compose will create.
Note on
depends_on: it controls start order, not readiness. Compose startsdbbefore
web, but doesn't wait for Postgres to finish booting. For true "wait until the DB accepts
connections," you'd add ahealthcheckondbanddepends_on: { db: { condition: service_healthy } }. That's beyond this lab, but good to know the distinction.Checkpoint
- Why does
dbneed a named volume andwebdoesn't? (The database has state to keep; nginx serving a default page doesn't.) - What does
depends_on: [db]guarantee — and what does it not? (Start order, not that Postgres is ready to accept connections.)
-
-
Bring the stack up with docker compose up -d
With the file written, bringing the whole stack to life is a single command.
Up, in the background
From inside
compose-lab(the folder holdingdocker-compose.yml):docker compose up -d-
upreadsdocker-compose.ymland creates everything it declares. -
-d(detached) runs it in the background and returns your prompt.
Watch the output. Compose does a lot for you, in order:
-
Creates the network
compose-lab_appnet(Compose prefixes names with the project, which
defaults to the folder name). -
Creates the volume
compose-lab_dbdata. -
Pulls
postgres:16-alpineandnginx:1.27-alpineif you don't already have them. -
Starts
dbfirst, thenweb, honoringdepends_on.
That ordering — network and volumes before containers, dependencies before dependents — is
exactly the fragile hand-sequence from Step 1, now done for you every time.See what's running
docker compose psYou'll see both services —
webanddb— with their state (running) andweb's port
mapping (0.0.0.0:8080->80/tcp). Unlike plaindocker ps,docker compose psis scoped to
this project, so you see only your stack.Open http://localhost:8080 — the "Welcome to nginx!" page confirms
webis up.Confirm the resources Compose created
docker network ls | grep appnet # compose-lab_appnet exists docker volume ls | grep dbdata # compose-lab_dbdata exists(On PowerShell, use
docker network ls | Select-String appnet.)These are the network and volume you never had to create by hand — Compose made them from your
file, anddocker compose downwill know how to remove them.Checkpoint
- In what order did Compose start the two containers, and why? (
dbbeforeweb, because ofdepends_on.) - Where did the network
compose-lab_appnetcome from? (Compose created it from thenetworks:block; the prefix is the project name.)
Deliverable for this step: the
docker compose psoutput showing both services up. -
-
Operate the lifecycle and prove web talks to db
A running stack isn't a black box. These commands are how you observe it, get inside it, and
prove the services actually reach each other.Follow the logs
docker compose logsaggregates the output of every service, each line prefixed with the
service name:docker compose logsYou'll see
dblines (Postgres reporting "database system is ready to accept connections") and
weblines together. To follow live, add-f:docker compose logs -fCtrl+Cstops following — it doesn't stop the containers. To watch just one service:docker compose logs -f dbOpen a psql shell in the database
docker compose execruns a command inside a running service (by service name, not
container id):docker compose exec db psql -U appuser -d appdbThat opens the PostgreSQL client inside the
dbcontainer, connected to theappdbdatabase
asappuser. Try a query, then quit:SELECT version(); \qYou just talked to the database with no Postgres client installed on your host — it's all
inside the container.Prove
webreachesdbby service nameThis is the heart of Compose networking. On the
appnetnetwork, each service is reachable
by its service name as a DNS hostname. So from insideweb, the database is simplydb.Exec into
weband resolvedb:docker compose exec web sh # now inside the web container: getent hosts db # prints db's internal IP — DNS resolved the service name nc -z -v db 5432 # (if nc exists) TCP connect to Postgres on the internal network exitEven just
getent hosts dbreturning an IP proves it:webresolved the namedbto the
database container over Compose's internal DNS, with no IP addresses hard-coded anywhere. In a
real app, your web service'sDATABASE_URLwould usedbas the hostname — e.g.
postgres://appuser:secret@db:5432/appdb.Stop and start without losing data
docker compose stop # stop both services, keep containers, network, volume docker compose ps # both show 'exited' docker compose start # bring them backBecause the database's data lives in the
dbdatavolume, stopping and starting — or even
downthenup— keeps every row. The volume outlives the container.Checkpoint
- Inside
web, what hostname reaches the database, and what makes that work? (db— Compose's internal DNS resolves service names on the shared network.) - Does
docker compose stopdelete your database data? (No — the named volume persists; onlydown -vwould remove it.)
Deliverable for this step: the
docker compose logsoutput showing both services, and the
getent hosts db(orpsql) output provingwebreachesdb. - Inside
-
Configuration: .env, validate, scale, best practices
Hard-coding
POSTGRES_PASSWORD: secretin the file is fine for a demo, but real stacks
externalize configuration. Compose has first-class support for this.Move variables into a
.envfileCompose automatically reads a file named
.envin the project folder and substitutes
${VARIABLE}references indocker-compose.yml. Create.env:# .env — do NOT commit this file POSTGRES_USER=appuser POSTGRES_PASSWORD=secret POSTGRES_DB=appdb WEB_PORT=8080Then reference the variables in
docker-compose.yml:services: web: image: nginx:1.27-alpine ports: - "${WEB_PORT}:80" depends_on: - db networks: - appnet db: image: postgres:16-alpine environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} volumes: - dbdata:/var/lib/postgresql/data networks: - appnet networks: appnet: driver: bridge volumes: dbdata:Now the same file works in dev, staging and prod — only the
.envchanges. There's also a
per-serviceenv_file:key if you want to point a service at a specific file.Validate before you run
docker compose configparses your file, applies variable substitution, and prints the final,
fully-resolved configuration — or a clear error if something's wrong:docker compose configRead the output:
${WEB_PORT}is now8080, the env vars are filled in. This is the fastest
way to catch a typo or a missing variable beforeup. (Notice, too, thatconfigdoesn't
emit aversion:key — confirming it's obsolete in v2.)Scale a service
Compose can run multiple replicas of a stateless service. Because a fixed host port can't
be shared by two containers, drop the fixedportsmapping (or use a range) before scaling,
then:docker compose up -d --scale web=3 docker compose ps # three web replicas, one dbYou now have three
webcontainers behind the one project — the basis of horizontal scaling.
(Don't scaledbthis way: databases are stateful and need real replication, not naive copies.)Best practices from the chapter
-
Use environment variables and
.envfor anything that changes between environments — keep the compose file itself stable. -
Never commit secrets. Add
.envto.gitignore; commit a.env.examplewith blank or dummy values so teammates know what's needed. - Use named volumes for stateful data (databases especially) so data survives container churn.
-
Isolate with private networks — only expose to the host the ports that truly need publishing; internal services talk over the Compose network, not
localhost. -
Give services clear names — the service name is the DNS hostname other services use, so name for the role (
db,web,cache), not the implementation. -
Keep the file readable — comment non-obvious blocks; validate with
docker compose configin CI.
Checkpoint
- How does Compose know to substitute
${WEB_PORT}? (It auto-reads the project's.envfile.) - What does
docker compose configdo, and why run it? (Prints the fully-resolved config; catches errors/typos beforeup.) - Why shouldn't
.envbe committed? (It holds secrets; commit a.env.exampletemplate instead.)
-
Use environment variables and
-
Submit: document your orchestrated stack
Prove you brought a real multi-service stack up, operated it, and showed the services talking —
by capturing the evidence in a small repository.Tear down cleanly first (so your outputs are honest)
When you're done experimenting:
docker compose down # removes containers + the network, keeps the named volume # docker compose down -v # add -v ONLY if you also want to delete the dbdata volumedownremoves the containers and theappnetnetwork. It keeps named volumes unless you
pass-v— that's the safety that stops you from wiping a database by accident.Build your submission
Create a git repo containing your
docker-compose.yml, your.env.example(dummy
values, safe to commit), a.gitignorethat ignores.env, and aSUBMISSION.mdwith your
real outputs. Paste actual command output — not invented text.# Docker Lab 3 — Orchestrate Services with Docker Compose ## 1. docker compose version <paste it — must show v2.x> ## 2. My docker-compose.yml <paste the final file: web + db, network, named volume, no `version:` key> ## 3. docker compose up -d <paste the output showing Compose create the network, the volume, and start db then web> ## 4. docker compose ps <paste it — both web and db running, web mapping HOST:80> ## 5. Logs from both services <paste docker compose logs lines showing BOTH `db` and `web` prefixes> ## 6. Proof web reaches db Output of `getent hosts db` (or a successful psql) run from inside the web container: <paste it — an internal IP for `db` proves service-name DNS works> ## 7. Configuration Output of `docker compose config` with my `.env` values substituted (note: no `version:` key): <paste it> ## Reflection (2-3 sentences) In my own words: what Compose declares that `docker run` makes me do by hand, and how `web` finds `db` without any hard-coded IP.Submit
git init echo ".env" > .gitignore git add docker-compose.yml .env.example .gitignore SUBMISSION.md git commit -m "Docker lab 3 — orchestrate services with Compose" # push to a public repo or create a gist, then submit that URLSubmit the repository or gist URL as your lab deliverable.
Submission criteria (self-check) — no stubs
-
docker-compose.ymlhas two services (web+db), a named volume, and a private network - The file has no top-level
version:key (you're on Compose v2) -
docker compose up -doutput shows Compose creating the network and volume and startingdbbeforeweb -
docker compose psshows both services running, withwebpublished on a host port -
docker compose logsoutput includes lines from bothdbandweb - You proved
webreachesdbby name (a realgetent hosts dbIP or a successfulpsqlconnection) — not just claimed it -
.envis git-ignored and a.env.examplewith dummy values is committed instead — no secrets in the repo - Your reflection correctly explains, in your words, what Compose declares and how service-name DNS works
Anything pasted must be real output from your machine. Placeholder or invented text fails
the check — the whole point is that you actually ran the stack.What's next
You've orchestrated services and seen them find each other by name over a private network — but
that networking was mostly automatic. In the next Docker lab you'll go deeper on Docker
networking: bridge vs host vs custom networks, how container DNS really works, and how to
control exactly which services can talk to which. -