Run a multi-container app with Compose
- Beginner
- 25 min
- Published
- Verified
What you'll learn
- Explain why an app and its database run in separate containers
- Connect two containers on a user-defined network and reach one by its name
- Describe services, ports, environment variables, and a volume in a
compose.yamlfile - Make one service wait for another to be healthy with
depends_on - Start, inspect, and tear down an app with
docker compose up,ps,logs, anddown
Your to-do app works on its own, storing its list in a SQLite file. Now you want it to use a real database server, MySQL, the way it would in production. That’s two programs, and each one has its own configuration, its own data, and its own port. They also have to find each other.
In this guide, you’ll run the app and MySQL as two containers and connect them
by hand, so you can see every moving part. Then you’ll write all of it down in
one compose.yaml file, and start, inspect, and remove the whole app with
Docker Compose.
Before you start
You can follow this whole guide in your browser. The terminals on this page are simulations: click through them to see what each command does and what it prints.
To follow along on your own machine, you need Docker Desktop, or Docker Engine with the Compose plugin. See Get Docker. Docker Desktop includes Compose.
Your output will differ in the details: container IDs, timings, and the IDs the app gives your to-do items.
Step 1: Get the app’s image
The sample app is the Docker getting-started to-do app, a small Node.js app with a web UI and an API. It has no published image, so build one straight from its Git repository. You don’t need to clone anything:
Host machine
docker build -t todo-app https://github.com/docker/getting-started-todo-app.git
[+] Building 83.4s (26/26) FINISHED docker:default => => exporting manifest list sha256:4edbe5e0f1d3396d664 0.0s => => naming to docker.io/library/todo-app:latest 0.0s => => unpacking to docker.io/library/todo-app:latest 4.4s
The build takes about a minute, and prints a line for every step it runs;
only the first and last few are shown here. When it finishes, you have an image named todo-app. How a build
like this works, and how to write the file that drives it, is the subject of
Write your first Dockerfile.
For this guide, you only need the image.
The app stores its to-dos in SQLite by default. When the environment variable
MYSQL_HOST is set, it connects to MySQL instead, using MYSQL_USER,
MYSQL_PASSWORD, and MYSQL_DB for the rest of the connection details.
That’s the switch you’ll flip.
Step 2: Decide what goes in which container
You could install MySQL inside the app’s image and start both programs in one container. Don’t. The usual practice is one process per container, because each piece can then be swapped, upgraded, restarted, and scaled on its own. You can move to a newer MySQL without rebuilding the app. You can restart the app without touching the database. And you can use the Docker Official Image for MySQL instead of maintaining your own.
So the app gets two containers, and they need three things:
- A network they share, so the app can reach the database. Only the app’s port is published to your machine. MySQL stays reachable only from inside the network.
- A way for the app to find the database, which is its container name,
mysql, passed inMYSQL_HOST. - A volume for the database’s files, so the data outlives the container. (To learn more about volumes, see Keep data with volumes and bind mounts.)
Step 3: Connect them by hand
Start with the network. A user-defined network, one you create yourself, gives every container on it a DNS entry under its container name. Docker’s default network doesn’t do that, which is why you create one:
Host machine
docker network create todo-net
37febc3776f54bc92ccc83471bb31821674d773b8ff454980d96a321077ca978
Next, start MySQL on that network. The MYSQL_ROOT_PASSWORD and
MYSQL_DATABASE variables tell the MySQL image to set a root password and
create an empty todos database the first time it starts. The -v flag
stores the database’s files in a volume named todo-mysql-data:
Host machine
docker run -d --name mysql --network todo-net -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=todos -v todo-mysql-data:/var/lib/mysql mysql:9.3
Unable to find image 'mysql:9.3' locally 9.3: Pulling from library/mysql 52c4516a26ee: Pull complete 66c8c73e9d3d: Pull complete a72970729c8f: Pull complete e45847b03d78: Pull complete d58a4ddc871c: Pull complete b6531c2f525e: Pull complete 008e8e968476: Pull complete 87befc648177: Pull complete 78b33d7af7bf: Pull complete cc63dd701044: Pull complete Digest: sha256:b9d8b7ec6e6aced08b1cfe50776f8e323c0a625adf4e10e69f90fc686ea10807 Status: Downloaded newer image for mysql:9.3 c8f7b0f6259864d8aec16db013943022d5dcf514940cc3da58dafceef4e83d60
The first run downloads the image, one line per layer. Notice there’s no -p: nothing on your machine needs to reach
MySQL directly, only the app does.
Now start the app on the same network. MYSQL_HOST=mysql is the container
name you just chose, and the other three variables match what you gave MySQL:
Host machine
docker run -d --name todo --network todo-net -p 3000:3000 -e MYSQL_HOST=mysql -e MYSQL_USER=root -e MYSQL_PASSWORD=secret -e MYSQL_DB=todos todo-app
8d93876b6e85f64c3c4030984a30640b3e27c2a1e4979067f3a754b76b7e1927
docker logs todo
Waiting for mysql:3306....... Connected! Connected to mysql db at host mysql Listening on port 3000
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 8d93876b6e85 todo-app "docker-entrypoint.s…" 11 seconds ago Up 10 seconds 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp todo c8f7b0f62598 mysql:9.3 "docker-entrypoint.s…" 36 seconds ago Up 35 seconds 3306/tcp, 33060/tcp mysql
The log shows the app looking up the name mysql, waiting while MySQL
finished starting (each dot is another try), and connecting. docker ps shows
the difference in ports: the app’s 3000 is published to your machine, while
MySQL’s 3306 is listed but not published.
Prove it end to end. Open http://localhost:3000 and add a to-do, or use the API, then ask MySQL what it has stored:
Host machine
curl -X POST -H "Content-Type: application/json" -d '{"name": "Learn Docker Compose"}' http://localhost:3000/api/items{"id":"ad126290-ef1f-40a8-a388-853d72aced19","name":"Learn Docker Compose","completed":false}docker exec mysql mysql -uroot -psecret todos -e "SELECT * FROM todo_items"
id name completed ad126290-ef1f-40a8-a388-853d72aced19 Learn Docker Compose 0
mysql: [Warning] Using a password on the command line interface can be insecure.
The to-do went through the app and landed in MySQL. The warning is MySQL reminding you that a password on the command line is visible to other users of the machine. That’s acceptable for a local experiment, not for real credentials.
What happens if you forget the network
Leave out --network, and the container joins Docker’s default network
instead, where container names don’t resolve. Try it with a second copy of
the app, with no published port so it doesn’t collide with the first:
Host machine
docker run -d --name todo-broken -e MYSQL_HOST=mysql -e MYSQL_USER=root -e MYSQL_PASSWORD=secret -e MYSQL_DB=todos todo-app
57e041ac649be8021f0a9ff7f67a4d74c28ffca0f1fd10215bc060c2b54c30d6
docker logs todo-broken
Waiting for mysql:3306............
Timeout
Error: getaddrinfo ENOTFOUND mysql
at GetAddrInfoReqWrap.onlookupall [as oncomplete] (node:dns:122:26) {
errno: -3007,
code: 'ENOTFOUND',
syscall: 'getaddrinfo',
hostname: 'mysql',
fatal: true
}ENOTFOUND means the name mysql doesn’t exist from where this container
sits. After about 10 seconds, the app gives up and the container exits. One
missing flag, and the app is down.
Count the cost
It works, but look at what you had to get right:
- Fourteen flags across three commands, where a single typo in a password or a name breaks the connection.
- The start order. MySQL has to be up before the app connects. This app happens to retry for about 10 seconds. Many apps don’t wait at all, and on a slow first start even 10 seconds might not be enough.
- The cleanup. Containers, the volume, and the network each come out separately:
Host machine
docker rm -f todo todo-broken mysql
todo todo-broken mysql
docker volume rm todo-mysql-data
todo-mysql-data
docker network rm todo-net
todo-net
And none of it is written down. A teammate who wants the same setup has to copy your shell history. That’s the problem Compose solves.
Step 4: Write it down in compose.yaml
Prefer to watch? This short video introduces Compose and walks through a Compose file. It predates this guide, uses a different sample app, and shows the result in the Docker Desktop Dashboard.
Docker Compose reads a YAML file that describes your app’s containers, and creates or removes them all for you. Each container you want is a service.
Make a new, empty directory and change into it. The directory’s name matters a little: Compose uses it as the project name, and prefixes everything it creates with it.
$ mkdir todo-compose
$ cd todo-composeCreate a file named compose.yaml with this content:
services:
app:
image: todo-app
ports:
- 3000:3000
environment:
MYSQL_HOST: mysql
MYSQL_USER: root
MYSQL_PASSWORD: secret
MYSQL_DB: todos
depends_on:
mysql:
condition: service_healthy
mysql:
image: mysql:9.3
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: todos
volumes:
- todo-mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 5s
retries: 5
volumes:
todo-mysql-data:Most of this is the docker run flags you already typed, moved into named
keys:
servicesholds one entry per container. The service’s name,appormysql, replaces--name, and it’s also the name other services use to reach it. That’s whyMYSQL_HOSTis stillmysql.imageis the image to run.appuses thetodo-appimage you built in Step 1.portsis-p, andenvironmentis a set of-eflags.volumesunder a service is-v. The top-levelvolumeskey declares the named volume, so Compose creates it if it doesn’t exist.depends_onwithcondition: service_healthytells Compose not to startappuntilmysqlis healthy.healthcheckdefines “healthy” formysql: Docker runsmysqladmin pinginside the container every 5 seconds, and the service is healthy once it answers. Without a healthcheck, Compose can only wait for the container to start, which happens before MySQL accepts connections.
There’s no network in the file. Compose creates one for every project and puts each service on it, so the service names resolve with no extra configuration.
Where each docker run flag goes in compose.yaml:
| Flag | In compose.yaml |
|---|---|
-p 3000:3000 |
ports: on the app service |
-e MYSQL_HOST=mysql |
environment: on the app service |
-v todo-mysql-data:/var/lib/mysql |
volumes: on the mysql service, plus the top-level volumes: declaration |
--network todo-net, network create |
Nowhere. Compose creates a network for the project and connects every service. |
--name mysql |
Nowhere. The service’s key under services: names it. |
Step 5: Start the app with one command
From the todo-compose directory, start everything. The -d flag runs the
containers in the background, the same as with docker run:
Host machine
docker compose up -d
[+] up 4/4 ✔ Network todo-compose_default Created 0.0s ✔ Volume todo-compose_todo-mysql-data Created 0.0s ✔ Container todo-compose-mysql-1 Healthy 5.6s ✔ Container todo-compose-app-1 Started 5.6s
One command created the network, the volume, and both containers. Every name
starts with the project name, todo-compose, so two projects on one machine
never collide. And the order is right: mysql became Healthy before
app started.
Check on the services, and read the app’s log:
Host machine
docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS todo-compose-app-1 todo-app "docker-entrypoint.s…" app 31 seconds ago Up 25 seconds 0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp todo-compose-mysql-1 mysql:9.3 "docker-entrypoint.s…" mysql 31 seconds ago Up 31 seconds (healthy) 3306/tcp, 33060/tcp
docker compose logs app
app-1 | Waiting for mysql:3306. app-1 | Connected! app-1 | Connected to mysql db at host mysql app-1 | Listening on port 3000
docker compose ps shows only this project’s containers, with the service
each one belongs to and the health of mysql. The log has a single dot this
time: by the time the app started, MySQL was already accepting connections.
Now prove the app works. Open http://localhost:3000 and add a to-do, or use the API:
Host machine
curl -X POST -H "Content-Type: application/json" -d '{"name": "Learn Docker Compose"}' http://localhost:3000/api/items{"id":"31aeb344-70cd-4fdd-9d75-31f71457605a","name":"Learn Docker Compose","completed":false}curl http://localhost:3000/api/items
[{"id":"31aeb344-70cd-4fdd-9d75-31f71457605a","name":"Learn Docker Compose","completed":false}]Step 6: Tear it down, and bring it back
docker compose down removes the project’s containers and its network:
Host machine
docker compose down
[+] down 3/3 ✔ Container todo-compose-app-1 Removed 0.1s ✔ Container todo-compose-mysql-1 Removed 1.3s ✔ Network todo-compose_default Removed 0.1s
The volume isn’t in that list. By default, down keeps volumes, because
they’re usually holding data you want back. Start the project again and the
to-do you added is still there:
Host machine
docker compose up -d
[+] up 3/3 ✔ Network todo-compose_default Created 0.0s ✔ Container todo-compose-mysql-1 Healthy 5.6s ✔ Container todo-compose-app-1 Started 5.6s
curl http://localhost:3000/api/items
[{"id":"31aeb344-70cd-4fdd-9d75-31f71457605a","name":"Learn Docker Compose","completed":false}]This time Compose created only the network and the containers: the volume already existed, and MySQL picked up its data from it.
When you’re done for good, add --volumes to remove the volume as well:
Host machine
docker compose down --volumes
[+] down 4/4 ✔ Container todo-compose-app-1 Removed 0.1s ✔ Container todo-compose-mysql-1 Removed 1.3s ✔ Network todo-compose_default Removed 0.1s ✔ Volume todo-compose_todo-mysql-data Removed 0.0s
What Compose did for you
Compare the two halves of this guide:
| By hand | With Compose |
|---|---|
docker network create, then --network × 2 |
A network per project, created automatically |
--name on each container |
Service names, resolvable on the network |
| You start MySQL first, and hope it’s ready | depends_on waits for the healthcheck |
| Flags in your shell history | A file you can commit next to your code |
Three kinds of rm to clean up |
docker compose down, plus --volumes for data |
Compose is declarative: the file describes what should exist, and
docker compose up makes it so. Run up again after editing the file, and
Compose recreates only the services whose configuration changed. It isn’t a
different kind of container. Underneath, it creates the same networks,
volumes, and containers you made by hand, which is why docker ps and
docker logs still work on them.
A Compose file also doesn’t replace a Dockerfile. A Dockerfile describes how to build one image. A Compose file describes which containers to run from which images, and how they connect.
A full development environment
The sample app’s own repository has a compose.yaml that goes further than
yours. It’s worth reading once you’ve cloned the repository:
$ git clone https://github.com/docker/getting-started-todo-app
$ cd getting-started-todo-app
$ docker compose up --watchThat file runs five services: the backend and the frontend, each built from
the repository’s Dockerfile into a development image, MySQL, phpMyAdmin for
browsing the database, and a Traefik proxy that serves the app at
http://localhost and phpMyAdmin at http://db.localhost. The --watch flag
turns on Compose Watch, which copies your source edits into the running
containers so the app reloads as you work. You don’t need it to finish this
guide. To learn more, see
Use Compose Watch.
Next
You ran an app from an image someone else’s Dockerfile built. To build images of your own, start the Build and share your own images path with Write your first Dockerfile.
Part of a learning path
This works on its own and is also included in the paths below. Open a path to see the complete route.
Learning path
Get started with Docker containers
Step 4 of 4
- Step 1: What is a container?
- Step 2: Run your first container
- Step 3: Keep data with volumes and bind mounts
- Step 4: Run a multi-container app with Compose (this page)
Table of contents
