CoursesDocker for beginnersMulti-container apps with Compose

Multi-container apps with Compose

One file, a whole stack.

Beginner12 min · lesson 14 of 16
In plain terms
Compose is the cast-and-setup list for a stage play, written in one script: who’s on stage, what props they need, how they’re wired together. “compose up” raises the curtain on the entire production at once, instead of walking each actor out by hand.

A real app is almost never one program running alone. A website needs somewhere to keep what people type into it, so it talks to a database (a program built to store information and find it again quickly). It probably wants a cache (a small, fast store for answers it looks up over and over) so pages don't crawl. Maybe a background worker that sends the signup emails. Each of those runs in its own container, an isolated box holding one program and everything that program needs to run. You can start every box by hand, in the right order, with the right settings, and hope you got all of it right. Do that three times before lunch and you will start avoiding it.

A conference room before an event is the same problem in physical form. Tables here, projector into that outlet, the speaker's chair near the front. Write it down once as a setup sheet and anyone on the team can lay the room out identically, every time, without asking you. Docker Compose is that setup sheet for your app. You describe every container once in a single text file, and one command reads the file and stands the whole thing up. Compose calls each container a service. The file is written in YAML (a plain-text format for settings) where the indentation is not decoration, it changes the meaning, so line things up carefully.

The one file that describes your whole app

Here is a small but complete stack. Stack means your app plus every part it leans on. In this case that is a web app you build from your own code, and a Postgres database (a widely used open-source database) where the app keeps its data. Save this as compose.yaml in your project folder.

compose.yaml
services:
web:
build: .
ports:
- "8080:3000"
environment:
DATABASE_URL: postgres://app:devsecret@db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: devsecret
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:

Read it top to bottom, like a parts list. Under services you have two entries, web and db. The line build: . tells Compose to build the web image from a Dockerfile sitting in the current folder (the dot means here, this folder). A Dockerfile is the recipe for your app, and the image is the finished, sealed package you get by following that recipe. ports maps 8080 on your machine onto 3000 inside the container, so you open the app at localhost:8080. environment sets variables the program reads when it starts. Look closely at the database address, postgres://app:devsecret@db:5432/app. That db in the middle is not a hostname you invented, it is the name of the other service. depends_on tells Compose to start db before web. The db service pulls a ready-made image, postgres:16, rather than building anything. Its volumes line points the database's data folder at a named volume called pgdata, which is storage that keeps existing after the container is gone. The volumes block at the bottom declares pgdata so Compose knows to create it. One more detail: there is no version: line at the top. Old tutorials open with one. Current Compose ignores it, so leave it out.

Start the whole stack with one command

That single command does four jobs. It builds anything that needs building, creates a private network for this stack alone, creates the volume, and starts both containers. The -d on the end is a flag, a short option you tack onto a command. Here it stands for detached, meaning Compose runs in the background and hands your terminal straight back to you.

terminal
$ docker compose up -d
[+] Building 6.4s (11/11) FINISHED
[+] Running 4/4
✔ Network myapp_default Created 0.1s
✔ Volume "myapp_pgdata" Created 0.0s
✔ Container myapp-db-1 Started 0.7s
✔ Container myapp-web-1 Started 1.0s

Look at what it built for you. The project name is myapp because that is the folder name, and Compose stamps that prefix onto everything it creates. It made a network called myapp_default and put every service on it automatically. On that network one container reaches another by service name, which is why web can say db and land on the database with no IP address written down anywhere. It made the volume myapp_pgdata for the database. Then it started two containers, myapp-db-1 and myapp-web-1.

Check what's actually running

docker compose ps lists the containers belonging to this stack, and the ports each one exposes.

terminal
$ docker compose ps
NAME IMAGE COMMAND SERVICE STATUS PORTS
myapp-db-1 postgres:16 "docker-entrypoint.s…" db Up 30 seconds 5432/tcp
myapp-web-1 myapp-web "node server.js" web Up 29 seconds 0.0.0.0:8080->3000/tcp

The PORTS column is where the story is. web shows 0.0.0.0:8080->3000/tcp. That means port 8080 on your host (your own machine) forwards into the container, so anything outside can knock on that door. db shows 5432/tcp with no host address in front of it. Reachable from inside the stack's network, invisible from your machine. For a database that is exactly what you want. Only web needs a door to the outside. The database stays behind it, and web talks to it over the private network.

One file, one command, a whole stack
docker compose up wires up the network and the volume so the services find each other by name. docker compose down takes it all apart again.

When a container won't start

On a clean machine this usually works on the first try. You will still hit failures, and the useful skill is reading the error instead of panicking. Run up while something else on your machine is already holding port 8080 and you get this.

terminal
$ docker compose up -d
[+] Running 1/2
✔ Container myapp-db-1 Started
⠠ Container myapp-web-1 Starting
Error response from daemon: driver failed programming external
connectivity on endpoint myapp-web-1: Bind for 0.0.0.0:8080 failed:
port is already allocated

Read the last line first. The bind for 0.0.0.0:8080 failed because the port is already allocated. Something on your machine has 8080 already: another Compose stack you forgot to stop, or a local dev server still running from this morning. Two ways out. Stop whatever is holding the port, or change the host side of the mapping in compose.yaml to a free one like 8081:3000 and run up again. Notice that db started fine and only web fell over, because web is the only service asking for a port on your host.

depends_on waits for the container, not for the program inside it
depends_on controls the order containers start in. It does not wait for the program inside to be ready to answer. Postgres needs a second or two after its container starts before it accepts connections, so a web app that dials the database the instant it boots can die on the first attempt with a connection refused error. Two fixes are common. Have your app retry the connection a few times before giving up, or add a healthcheck to db and write depends_on with condition: service_healthy, which makes Compose wait until the database actually responds before it starts web.

Tear it back down

When you're finished, one command stops and removes the containers and the network. Your named volume stays put, so the database data is waiting for you the next time you bring the stack up. Add -v only when you genuinely want that data gone.

terminal
$ docker compose down
[+] Running 3/3
✔ Container myapp-web-1 Removed 0.4s
✔ Container myapp-db-1 Removed 0.3s
✔ Network myapp_default Removed 0.1s
$ docker volume ls
DRIVER VOLUME NAME
local myapp_pgdata

Notice that myapp_pgdata still shows up in docker volume ls after down. That is the safety net. Your data does not evaporate because you stopped the app. And the whole setup now lives in one file you can commit to git, so a teammate clones the repo, runs the same command, and gets an identical stack.

The real shift here is where the knowledge lives. Before Compose, the settings for a stack lived in your shell history and in your head: a dozen docker run flags you retyped from memory and got slightly wrong on Fridays. Now the services, the network and the volumes sit in a file you can read, review, diff and roll back like any other code. up brings the stack into existence, down removes it. Web plus database is the classic pairing, and it is the point where a demo turns into something a colleague can start without calling you first.

Service names double as network names. Call the service db and db becomes the address, which is why your app connects to postgres://db:5432. That is DNS (the Domain Name System, the phone book that turns names into addresses) working inside your stack. That naming contract buys you more than any advanced Compose feature will. Pick service names that are short, obvious and stable, because renaming one means editing every connection string that points at it.

Keep beginner Compose files boring on purpose: image or build, ports, environment, volumes, and depends_on as a rough ordering hint. Those five keys cover almost everything a two or three service app needs. The format supports dozens of other options, and you can ignore them until something forces you to care. The one thing those five will not do is wait until Postgres is ready to answer, so the retry logic stays in your app.

When a service misbehaves, two commands come first. docker compose ps tells you whether it is even up, and docker compose logs service prints what it said on the way down. After you edit the file, docker compose up -d --force-recreate rebuilds the containers from the new settings instead of reusing the old ones. Resist the urge to fix a container by hand from the inside. Compose will not remember what you did, and the next up wipes it. The file is the source of truth, so change the file.

One habit worth starting early: that environment block holds a password in plain text, sitting in the file. For learning on your own machine that is fine, and an env file (a small file of KEY=value lines that Compose reads for you) is a reasonable next step. The line to never cross is committing a real password next to the compose.yaml you push to GitHub. A throwaway local secret like devsecret does no harm. A production credential in git does.

Try this

Write your own two-service compose file, bring it up detached, look at ps, read the last few lines of the web service's logs, then take the whole thing back down.

terminal
docker compose version
docker compose up -d
docker compose ps
docker compose logs --tail=20 web
docker compose down
output
Docker Compose version v2.x.x
[+] Running 3/3
✔ Network ... Created
✔ Container ...db Started
✔ Container ...web Started
NAME IMAGE STATUS PORTS
... ... Up 10 seconds 0.0.0.0:8080->80/tcp

Takeaway

Remember one line from this lesson: in Compose, the service name is the hostname. Name it db in the file and db is what your app connects to, on a private network Compose creates for you on up and removes on down. The file is what makes that repeatable, not your shell history.

Quick check
01In the compose.yaml above, how does the web service actually find the database?
Correct — Both services land on one auto-created network and each answers to its service name, so web reaches the database at db with no IP address written anywhere.
Incorrect — No. Container IP addresses change on every restart, and you never write one into this file. The stable name db exists so you never have to chase them.
Incorrect — No. 8080 is only the door for reaching web from outside your machine. The two services talk over the internal network, and db is not published to the host at all.
Incorrect — No. links is a leftover from older Docker and you do not need it. Services written in the same Compose file share a network and resolve each other by name on their own.
02The web service carries depends_on: [db]. What does that line actually promise you?
Incorrect — It waits for db's container to start and nothing more. Postgres inside that container may still be warming up.
Incorrect — Name resolution comes from the shared network Compose creates, not from depends_on.
Correct — depends_on controls start order only. A web app that connects the moment it boots can still get connection refused while Postgres finishes starting.
Incorrect — depends_on says nothing about restarts. It only affects the initial start order.
03You run docker compose down, come back later, run up again, and your Postgres data from before is still there. What would have destroyed it instead?
Correct — Plain down leaves named volumes alone. down -v is the command that deletes pgdata and every row inside it.
Incorrect — down keeps named volumes on purpose, which is exactly why your rows were waiting for you.
Incorrect — Rebuilding the web image does not touch the named volume the database writes to.
Incorrect — The host port mapping and the volume holding the database files have nothing to do with each other.

Related