Multi-container apps with Compose
One file, a whole stack.
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.
services:web:build: .ports:- "8080:3000"environment:DATABASE_URL: postgres://app:devsecret@db:5432/appdepends_on:- dbdb:image: postgres:16environment:POSTGRES_USER: appPOSTGRES_PASSWORD: devsecretPOSTGRES_DB: appvolumes:- pgdata:/var/lib/postgresql/datavolumes: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.
$ 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.
$ docker compose psNAME IMAGE COMMAND SERVICE STATUS PORTSmyapp-db-1 postgres:16 "docker-entrypoint.s…" db Up 30 seconds 5432/tcpmyapp-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.
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.
$ docker compose up -d[+] Running 1/2✔ Container myapp-db-1 Started⠠ Container myapp-web-1 StartingError response from daemon: driver failed programming externalconnectivity 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.
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.
$ 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 lsDRIVER VOLUME NAMElocal 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.
docker compose versiondocker compose up -ddocker compose psdocker compose logs --tail=20 webdocker compose down
Docker Compose version v2.x.x[+] Running 3/3✔ Network ... Created✔ Container ...db Started✔ Container ...web StartedNAME 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.