Guide

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.yaml file
  • 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, and down

The terminals below are simulated. Nothing runs on your machine, and each one starts from the state the guide has reached at that point.

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:

The to-do app and MySQL on one Docker network On your machine, a browser reaches the todo container through the published port 3000. The todo container and the mysql container sit on the same user-defined network, todo-net. The todo container is configured with MYSQL_HOST=mysql and reaches the database by its container name on port 3306. The mysql container stores its data in the todo-mysql-data volume at /var/lib/mysql. YOUR MACHINE Browser localhost:3000 NETWORK · todo-net todo image: todo-app listens on 3000 MYSQL_HOST=mysql mysql image: mysql:9.3 listens on 3306 volume todo-mysql-data -p 3000:3000 mysql:3306 /var/lib/mysql
  • 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 in MYSQL_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-compose

Create 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:

  • services holds one entry per container. The service’s name, app or mysql, replaces --name, and it’s also the name other services use to reach it. That’s why MYSQL_HOST is still mysql.
  • image is the image to run. app uses the todo-app image you built in Step 1.
  • ports is -p, and environment is a set of -e flags.
  • volumes under a service is -v. The top-level volumes key declares the named volume, so Compose creates it if it doesn’t exist.
  • depends_on with condition: service_healthy tells Compose not to start app until mysql is healthy.
  • healthcheck defines “healthy” for mysql: Docker runs mysqladmin ping inside 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 --watch

That 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.

Learning paths

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.