Imagine you and two classmates are building a mini project for your school's tech exhibition: an online train-ticket booking demo, styled after IRCTC. It needs three separate pieces running at the same time — a web server that shows the booking form, a database that stores seats and bookings, and a cache that remembers which seats are "locked" while someone is paying, so two students can't grab the same seat. Each of these pieces runs in its own Docker container. The moment your project needs more than one container that must start together, find each other on a network, and shut down together, you have hit the exact problem that Docker Compose was built to solve. This chapter is about that problem, and the one YAML file that solves it.
Where the word "orchestration" comes from
Before Docker, before computers, the word "orchestration" already meant something specific: a conductor standing in front of an orchestra, making sure the violins, the drums, and the trumpets all start at the right moment, play at the right tempo, and can hear each other well enough to stay in sync. No single musician decides this alone — the conductor holds the whole score and coordinates everyone.
Container orchestration means exactly this, applied to software. When your ticket-booking app needs a web server, a database, and a cache to start in the correct order, sit on the same private network, and know each other's names, you need something that holds the "whole score" — a single description of every container and how they relate. Docker Compose is that conductor for containers running on one machine. (When the containers need to run across many machines — a real airline's booking system, for example — a bigger orchestration tool called Kubernetes takes over. You will likely meet Kubernetes in a later chapter; Compose is the right tool for a single laptop, a single classroom server, or a single small deployment.)
The problem, in real commands
Suppose you have not heard of Compose yet, and you try to start your three-container ticket app by hand, using the docker run command you already know from single-container work. First you'd need a private network so the containers can talk to each other by name instead of by IP address:
docker network create ticketnet
Then you start the database, giving it a name, attaching it to that network, passing three environment variables so Postgres knows what user and database to create, and mounting a volume so the data survives a restart:
docker run -d \
--name ticketdb \
--network ticketnet \
-e POSTGRES_USER=student \
-e POSTGRES_PASSWORD=examroom \
-e POSTGRES_DB=ticketdb \
-v pgdata:/var/lib/postgresql/data \
postgres:16
Then the cache:
docker run -d \
--name ticketcache \
--network ticketnet \
redis:7
And finally the web server, which needs to know how to reach both the database and the cache, plus a mapped port so your browser on the host machine can reach it:
docker run -d \
--name ticketweb \
--network ticketnet \
-p 5000:5000 \
-e DATABASE_URL=postgresql://student:examroom@ticketdb:5432/ticketdb \
-e CACHE_URL=redis://ticketcache:6379 \
ticketapp-web:latest
Count what you just typed: 1 network command, and 3 docker run commands carrying a combined 8 flags across them, each one easy to mistype, forget, or get out of order — and you have to remember to run them in exactly this order every single time, or the database won't exist yet when the web server tries to connect. Tear it all down and you must also remember to individually stop and remove three containers and the network. This is the pain Compose exists to remove.
One file instead of four commands
Docker Compose lets you write everything above — the network, the three containers, their settings, and their relationships — as a single declarative file, conventionally named docker-compose.yml, sitting in your project folder. "Declarative" means you describe the end state you want (these three services, connected this way) rather than the sequence of commands to get there. Compose reads the file and works out the commands itself.
Here is the ticket-booking app described as a Compose file:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: student
POSTGRES_PASSWORD: examroom
POSTGRES_DB: ticketdb
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U student -d ticketdb"]
interval: 5s
timeout: 3s
retries: 5
cache:
image: redis:7
web:
build: ./web
ports:
- "5000:5000"
environment:
DATABASE_URL: postgresql://student:examroom@db:5432/ticketdb
CACHE_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
volumes:
pgdata:
This single file replaces every command from the previous section. Notice that it does not include a version: line at the top — older tutorials you may find online often start with something like version: "3.8", because that used to be required. Current Docker (the "Compose Spec" format) no longer needs it and will print a harmless warning if you include it, so new files should leave it out.
Reading the file: what each key means
Every Compose file has one required top-level key, services, which is a list of the containers you want. Under each service name (db, cache, web here), you describe that one container:
imagetells Compose to pull a ready-made image from a registry like Docker Hub —postgres:16means "Postgres, version 16," exactly as you would type afterdocker run.buildtells Compose to build the image itself from a Dockerfile, instead of pulling one.build: ./webmeans "look inside thewebfolder for a Dockerfile and build an image from it." You useimagefor off-the-shelf software andbuildfor your own code.portsmaps a port on your host machine to a port inside the container, written"host:container"."5000:5000"means requests tolocalhost:5000on your laptop get forwarded to port 5000 inside the web container. Only services a human or an outside program needs to reach directly get aportsentry — noticedbandcachedon't have one, because only thewebservice needs to be reachable from your browser.environmentsets environment variables inside the container, exactly like-eondocker run— this is how the Postgres image knows which username, password, and database name to create on first boot, and how your web app's code knows what connection string to use.volumesmounts persistent storage.pgdata:/var/lib/postgresql/datameans "create (or reuse) a named volume calledpgdataon the host, and mount it at that path inside the container." Without this, every time you tore down and rebuilt the containers, all your stored bookings would vanish, because a container's own filesystem is deleted along with it.depends_ontells Compose the order in which to start containers, and you'll look at exactly what it does and does not guarantee in the next section.healthcheckgives Compose a command to run periodically inside the container to check if it is actually ready to serve requests, not just "started."
At the bottom, the top-level volumes: key (separate from the per-service volumes: list) declares that pgdata is a named volume Compose should manage.
Tracing what actually happens on docker compose up
Run this from the folder containing the file:
docker compose up
Note the space, not a hyphen — current Docker ships compose as a built-in subcommand of the docker CLI. (You may see older material use a separate, hyphenated docker-compose program; that was version 1, a standalone Python tool, and Docker stopped shipping it by default some years ago in favour of the integrated docker compose.) Trace what Compose does, step by step:
- It reads
docker-compose.ymlin the current folder and names the whole group of containers after the folder (say your folder isticketapp, the project is namedticketapp). - It creates one shared network for this project, named
ticketapp_default, if it doesn't already exist — replacing your manualdocker network createstep. - It creates the named volume
ticketapp_pgdata, if it doesn't already exist. - It builds the
webimage from./web/Dockerfile, and pullspostgres:16andredis:7from Docker Hub if they are not already cached on your machine. - It starts
dbandcache. Since neither depends on the other, Compose may start them at the same time. - It waits — because of
condition: service_healthy— until thedbcontainer's healthcheck command (pg_isready) succeeds, meaning Postgres has actually finished initializing and is accepting connections, not merely that the container process has started. - Only once
dbis healthy andcachehas started does it startweb. - It streams the logs of all three containers to your terminal, prefixed by service name, until you press Ctrl+C.
How the containers find each other: names, not IP addresses
Look again at the web service's environment variable: DATABASE_URL=postgresql://student:examroom@db:5432/ticketdb. The hostname is literally the word db — the service's name in the Compose file, not an IP address. This works because every container on a Compose-created network gets automatic DNS: Docker runs an internal name-resolution service on that network, so any container can reach another simply by using its service name as if it were a hostname. This is the single most useful fact about Compose networking, and it's also why you never had to hardcode an IP address anywhere in the file — IP addresses assigned to containers can change across restarts, but service names never do.
The diagram below shows the same ticket-booking project: three containers sharing one private network inside the host machine, with only web exposed to the outside through a mapped port.
The misconception: what depends_on does not promise
A very common mistake — one you will see in real beginner projects — is assuming that a plain depends_on: [db] guarantees the database is ready to accept queries before the dependent service starts. It does not. In its simplest form, depends_on only guarantees container start order: Compose will start the db container's process before starting web's. But "the Postgres process has started" and "Postgres has finished initializing and is ready to accept connections" are two different moments — Postgres can take a second or two to set up its data files on first boot. If your web app tries to connect during that gap, it gets a connection-refused error and may crash, even though depends_on was written correctly.
This is precisely why the Compose file above adds a healthcheck to db and uses condition: service_healthy in web's depends_on, instead of the bare list form. The healthcheck runs pg_isready repeatedly (every 5 seconds, up to 5 tries, per the config) until it succeeds, and only then does Compose consider db truly ready and start web. Without this, a Compose file that "looks correct" can still fail intermittently — working fine on a fast machine where Postgres initializes quickly, and failing on a slower one where it doesn't. Well-written production code also adds its own retry logic on top of this, since even a healthcheck can't cover every kind of temporary unavailability, but for a CBSE-level understanding, the key fact to remember is: plain depends_on orders container starts; it does not wait for the application inside to be ready — that needs an explicit healthcheck.
A worked numeric example: how many valid start orders exist?
The dependency relationships in a Compose file form a small graph, and it's worth counting exactly how much freedom Compose actually has. In our file, web depends on both db and cache, but db and cache have no dependency between each other.
With 3 services, if you ignored the dependency rules entirely, there would be 3! = 3 × 2 × 1 = 6 possible orders to start them in. But only the orders where web comes after both db and cache are valid. Fix web in the last position; the remaining two services, db and cache, can be arranged in the first two positions in 2! = 2 × 1 = 2 ways — (db, cache, web) or (cache, db, web). So exactly 2 out of the 6 total orderings respect the dependency graph, a fraction of 2/6 = 1/3. This is exactly why you cannot rely on "start them in the order they're written" or "start them alphabetically" — of the 6 naive orderings, 4 would be wrong, including starting web first. Compose instead performs what computer scientists call a topological sort on the dependency graph: an ordering of items such that every item comes after everything it depends on. This is the same underlying idea used for problems like "in what order should you take courses when some are prerequisites for others."
The command set you actually use day to day
Beyond docker compose up, a small set of commands cover almost all classroom and small-project use:
docker compose up -d # start everything in the background
docker compose ps # list this project's running containers
docker compose logs -f web # stream only the web service's logs
docker compose exec db psql -U student -d ticketdb # open a shell inside a running container
docker compose down # stop and remove containers + network (keeps volumes)
docker compose down -v # also delete named volumes — this erases stored data
The distinction between down and down -v matters: down alone is safe to run repeatedly while you develop, because your pgdata volume — and every booking stored inside it — survives. Adding -v deletes that volume too, which you'd only want when you deliberately want to reset the database to empty.
Scaling a service — and why one line in this file blocks it
Compose can also run several copies of the same service, which is useful for load-testing or simulating multiple worker processes:
docker compose up -d --scale web=3
Try this against the file above, though, and it fails with a port-allocation error. The reason is directly traceable to one line you already read: ports: - "5000:5000" fixes host port 5000 to this service. Only one process on your machine can bind to a given host port at a time, so three copies of web all trying to claim port 5000 is a contradiction — the second and third copies simply cannot start. To scale a service like this in practice, you either drop the fixed host-port mapping and put a load balancer in front (which itself gets the fixed port and forwards to whichever backend copy is free), or you let Docker pick a random free host port per copy by writing just the container port, e.g. ports: - "5000". This is a good example of how a Compose file's settings interact with each other in ways that only show up once you try to do something the file wasn't written for.
Practice: active recall
- Write, from memory, the top-level Compose key that lists the containers you want to run, and the two ways (under a service) you can tell Compose to obtain that container's image.
- A classmate writes
depends_on: [db](the plain list form, no healthcheck) for a service that queries MySQL immediately on startup. Explain, in your own words, the specific gap this leaves open, and why the app might crash on a slow machine but work fine on a fast one. - In a Compose file, service
apidepends on bothauthanddb; servicedbadditionally depends ondb-migrate. Draw the dependency graph and list every valid start order. - Two students argue about whether
docker-compose upanddocker compose upare the same thing today. Settle the argument with the correct technical distinction. - A Compose file gives service
workernoportsentry at all, whilewebhasports: - "5000:5000". Canwebstill reachworker? Can your laptop's browser reachworkerdirectly? Justify both answers from what you now know about Compose networking. - Explain, using the orchestra analogy, what specific job Docker Compose is doing that plain
docker runcommands, typed one after another, do not do for you.
Summary
A real application is almost never one container — it's a small set of cooperating processes, each with its own image, its own settings, and specific relationships to the others. Typing out each container's docker run command by hand is slow, error-prone, and forces you to remember a strict start order every time. Docker Compose replaces that with one declarative YAML file: a services block describing each container (its image or build path, ports, environment variables, volumes, and healthchecks), plus depends_on to describe the dependency graph between them. Running docker compose up reads that file and does everything for you — creating a shared network on which containers resolve each other by service name through Docker's built-in DNS, creating named volumes for data that must survive restarts, and starting containers in an order that respects the dependency graph, which for even a small 3-service graph rules out most of the naive orderings you might have guessed. The one fact worth remembering above all others: plain depends_on only orders container starts, not application readiness — for that you need an explicit healthcheck and a condition: service_healthy. And Compose itself has a boundary — it orchestrates multiple containers on one machine; coordinating containers across many machines is a job for Kubernetes, a tool built on the same core ideas at much larger scale.