Multi-container apps with Compose
One file, a whole stack.
A typical app is more than one process: a web app, a database, often a cache, sometimes a worker. Each usually runs in its own container. Starting them by hand, in order, with the right flags, is easy to get wrong and tedious to repeat.
Docker Compose describes the whole stack in one YAML file: each container (Compose calls it a service), the network, and the volumes. One command reads the file and starts everything. Indentation in YAML is significant.
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. Under services you have web and db. build: . builds the web image from a Dockerfile in the current directory. ports maps host 8080 to container 3000. environment sets process variables. In postgres://app:devsecret@db:5432/app, db is the other service name, not a made-up hostname. depends_on starts db before web. The db service uses postgres:16. volumes bind the data directory to named volume pgdata, declared at the bottom so Compose creates it. There is no version: key; current Compose ignores it.
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 difference is where the settings live. Without Compose they sit in shell history. With Compose, services, network, and volumes are a file you can review and commit. up starts the stack; down removes the containers and network. Web plus database is the usual first multi-container app.
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.