Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Describe an application with Compose: a first stack

By updated

Improve this page

Logo Docker

You know how to start a container with docker run, publish a port, create a network and mount a volume. For an application with two services, that already makes three long commands, to be typed in the right order, and to be dug out of the terminal history the day you have to start over. Docker Compose replaces these commands with a file, compose.yaml, which describes the application, and with one command, docker compose up -d, which starts it.

This lesson is for a beginner who has finished the previous modules of the course. You write a first stack, that is, an application described in a single file, here with two services (two containers): a web page and a database. You check the configuration before starting it, you start the application, then you learn the everyday commands, down to the difference between down and down -v. Two topics have a lesson of their own: using several networks, and the traps of recreating containers, such as permissions on the data.

What you will learn

  • Write a two-service compose.yaml file, with their image, their published port, an environment variable and a named volume.
  • Display the effective configuration with docker compose config, before starting anything.
  • Start, list and stop the application with up -d, ps and stop.
  • Understand how the project name names the containers, the network and the volume, and have one service reach another by its name.
  • Follow the logs with logs -f, run a command in a service with exec, fetch images with pull.
  • Know what down removes, and what down -v destroys on top of that.

Prerequisites

You need a Linux host with Docker Engine 29.9.2 and its Compose 5.6.1 plugin, installed from the official repository as shown in the lesson on installing Docker. The command is written docker compose, with a space. The old Compose v1, written in Python, was called docker-compose, with a hyphen: it is no longer maintained.

You must know how to start a container and read its list (starting a container and choosing what it runs), publish a port (publish a port), make two containers talk by name on a network (Docker networks) and keep data in a named volume (Docker volumes). This lesson does not explain them again, it describes them in a file. That file is written in YAML: being able to read its lists, its key-value pairs and its indentation is enough, and the lesson basic YAML syntax (in French) introduces them.

The outputs on this page were taken on a Debian 13 machine. Both images are pinned by their SHA-256 digest (the @sha256:... that follows the tag), which designates one precise version of the image: you get exactly the same ones. You run the commands from the ~/boutique folder, which the lesson has you create empty.

Why describe an application in a file?

Because a file can be reread, versioned and replayed identically, whereas a series of commands typed by hand gets lost. Compose describes the desired state of the application, and Docker creates the objects that are missing to reach it.

Take the application of this lesson: a page served by nginx and a postgres database that keeps its data in a volume. In this stack, nginx does not talk to postgres: the two services are simply on the same project network, and the lesson shows how one reaches the other. By hand, you would write these three commands.

Terminal window
# What Compose is going to replace: do not run them
docker network create boutique
docker run -d --name base --network boutique -e POSTGRES_PASSWORD=demo-seulement -v donnees:/var/lib/postgresql postgres:18.6-alpine3.24@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5
docker run -d --name site --network boutique -p 127.0.0.1:18410:80 nginx:1.30.6-alpine@sha256:0e52bd054126cdc040b1276c0af347aeb02a41b9cfe305d6094767ac30e97949

Each option of these commands has an equivalent in the file. The diagram sets them side by side: on the left, what you type with docker run, on the right, the line of the compose.yaml that says the same thing. The first five arrows are plain translations (the container of --name site will be named boutique-site-1 here, as the page shows further down). Look above all at the last two: --network and docker network create point to a box with no equivalent to write, because Compose creates the network and attaches all the services to it for you.

Two columns linked by arrows. On the left, the pieces of the docker run commands: --name, the image, -p, -e, -v and --network, plus docker network create. On the right, the compose.yaml: the service name under services, image, ports, environment and volumes. --name goes to the service name, the image to image, -p to ports, -e to environment, -v to volumes. --network and docker network create both go to a single red box: no line to write, the network is created by Compose.With docker runIn compose.yaml--nameimage-p-e-v--networkdocker networkcreateservice nameunder services:image:ports:environment:volumes:no line needed:network created
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

How do you write the first compose.yaml file?

Create a boutique folder and this file inside it. The name compose.yaml is the one Compose looks for first in the current folder.

Terminal window
mkdir ~/boutique && cd ~/boutique
services:
site:
image: nginx:1.30.6-alpine@sha256:0e52bd054126cdc040b1276c0af347aeb02a41b9cfe305d6094767ac30e97949
ports:
- "127.0.0.1:18410:80"
base:
image: postgres:18.6-alpine3.24@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5
environment:
POSTGRES_PASSWORD: demo-seulement
volumes:
- donnees:/var/lib/postgresql
volumes:
donnees:

The file has two top-level blocks. services: lists the containers of the application, each under the name you choose for it: here site and base. That name matters, since it is the one the other services will use to reach it. volumes: at the bottom declares the named volumes that the services mount.

In a service, image: states the image to run. ports: takes up the syntax of -p, with the same listening address 127.0.0.1: the page stays reachable from the machine only, without opening up to the network.

The quotes around the port are a habit recommended by the Compose documentation. They are optional here, and the following note gives the reason.

environment: sets the container's environment variables. The postgres database refuses to start without POSTGRES_PASSWORD. The password is written in clear text in the file because this is a demonstration: never do it on a real project. Environment files are the subject of the lesson "Interpolation and environment files", and secrets management of the module "Making a stack reliable".

Finally donnees:/var/lib/postgresql mounts the named volume donnees in the folder where the postgres 18 image keeps its data, in the same form as -v. The volume is declared at the bottom of the file, with no other option: Docker creates it with its default settings.

Notice the shape of the fields. ports: and volumes: are lists, because a service can publish several ports or mount several volumes: each item starts with a dash. environment: is a series of key: value pairs, because each variable has a name and a value.

Indentation matters in YAML: two spaces per level, never a tab. A shifted line changes the meaning of the file, or makes it unreadable for Compose. That is the point of the section "How do you check what Compose will create?".

How do you check what Compose will create?

With docker compose config, which reads the file, validates it and displays the effective configuration, that is, what Compose will really launch, once all the default values have been added. Nothing is created: it is a read-only operation, with no danger.

Terminal window
docker compose config
name: boutique
services:
base:
environment:
POSTGRES_PASSWORD: demo-seulement
image: postgres:18.6-alpine3.24@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5
networks:
default: null
volumes:
- type: volume
source: donnees
target: /var/lib/postgresql
volume: {}
site:
image: nginx:1.30.6-alpine@sha256:0e52bd054126cdc040b1276c0af347aeb02a41b9cfe305d6094767ac30e97949
networks:
default: null
ports:
- mode: ingress
host_ip: 127.0.0.1
target: 80
published: "18410"
protocol: tcp
networks:
default:
name: boutique_default
volumes:
donnees:
name: boutique_donnees

Compare this output to your file. Compose gave a name to the application (name: boutique, taken from the folder), attached the two services to a default network, and prefixed the network and the volume with that name. It also spelled out your ports: line as an address, a host port and a container port. If you see here something other than what you thought you wrote, fix the file before launching anything.

Syntax errors, field-name errors and indentation errors stop here too: docker compose config answers with an error message and shows nothing.

A tab or a misplaced character gives, for example, go-yaml load error in scanner (while scanning for the next token) at L2.C2: found character that cannot start any token, which indicates the line and the column.

A misspelled field, such as imag: instead of image:, gives validating /home/<compte>/boutique/compose.yaml: services.site.imag false schema. Compose writes the full path of the file: on your machine, /home/<compte> is your home directory, the one that ~ stands for in ~/boutique. The message also names the unknown field and its path in the file, here the imag field of the site service.

How do you start the application and see what runs?

A single command creates the network, the volume and the two containers. The -d option gives the terminal back, and the containers run in the background.

Terminal window
docker compose up -d
[+] up 4/4
✔ Network boutique_default Created 0.0s
✔ Volume boutique_donnees Created 0.0s
✔ Container boutique-site-1 Started 0.2s
✔ Container boutique-base-1 Started 0.4s

Each line announces a created object: the network, the volume, then one container per service. If you rerun the same command without touching the file, Compose recreates nothing. If you change a service in the file, for example its image, docker compose up -d recreates only that service and leaves the other one running.

Then list the containers of the project with docker compose ps. The format in the example keeps three columns so that the output fits on the page.

Terminal window
docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'
SERVICE STATUS PORTS
base Up 5 seconds 5432/tcp
site Up 5 seconds 127.0.0.1:18410->80/tcp

Look at the PORTS column. The site service publishes port 80 on port 18410 of the address 127.0.0.1, as you asked. The base service only shows 5432/tcp: that port exists in the container, but nothing publishes it on the machine. The database therefore stays invisible from the outside, and yet site can reach it, as the section "How does one service reach another service?" shows.

Finally, check that the page answers.

Terminal window
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18410/
200

The HTTP code 200 means that the request succeeded: nginx answers and serves its default home page. You now have a running application, started by a file and not by three commands.

How does Compose name what it creates?

Compose files everything it creates under a project name. By default, it is the name of the folder that contains the compose.yaml, here boutique. The names you read above follow from it: the network is <project>_default, the volume <project>_<volume>, and each container <project>-<service>-<number>, hence boutique-site-1.

This name has a practical role: Compose finds its objects through it. docker compose ps, logs or down act only on the containers, networks and volumes of this project, and you can stop your application without touching the other containers of the machine.

You can impose another project name in three ways. The -p option sets it for one command, for example docker compose -p demo up -d. The COMPOSE_PROJECT_NAME environment variable sets it for all the commands of the terminal. A name: line at the top of the file sets it for everyone who uses it. If several are present, -p wins over the variable, which wins over name:.

Whatever the method, use the same name on every command. Otherwise two mistakes are possible, and their effects differ.

A down run under another name removes nothing and creates nothing: docker compose -p autre down answers No resource found to remove for project "autre", and the boutique application keeps running.

An up -d run under another name, on the contrary, creates a second complete set of containers, network and volume (autre-site-1, autre-base-1). With the fixed port of the page, this second set collides with the first: Bind for 127.0.0.1:18410 failed: port is already allocated, and the autre-site-1 container stays in the Created state.

To see the projects that Compose manages on the machine, run docker compose ls. It lists the running projects with their state, and docker compose ls -a adds the stopped ones. For its part, docker compose -p boutique ps queries the boutique project from any folder.

How does one service reach another service?

It uses its service name as a host name. As you saw with a network created by hand, Docker resolves the names of the containers of a same user-defined network. Compose creates that network for you and puts all the services on it: site reaches base by simply writing base.

Ask site which address matches that name. The getent hosts command queries the container's name resolution, as an application would.

Terminal window
docker compose exec site getent hosts base
172.18.0.3 base base

The output gives an address of the project network, followed by the name, which the tool prints twice. On your machine, the address may differ, because Docker picks the subnet.

A resolved name does not prove that a connection succeeds, as the lesson on networks reminds you: test a real connection.

The nginx alpine image provides nc (the BusyBox one). The -z option opens the connection without sending data, and -w 3 gives up after three seconds. The echo only prints if nc succeeds.

First give the database time to start. Right after the first up -d, it is initializing and is not listening yet: nc then prints nothing. Simply rerun the command a few seconds later, until you read ouvert (French for "open"); on the measurement machine, the connection succeeded on the second try, one second after the first.

Terminal window
docker compose exec site nc -z -w 3 base 5432 && echo ouvert
ouvert

The word ouvert proves that site reaches port 5432 of base. If the port is closed, nc prints nothing and returns code 1, and echo does not run. Try with port 5433, which the database does not listen on: nothing is printed.

Remember that the name base is the name of the service in the file, and not that of the container boutique-base-1. The address can change at each recreation, whereas the service name stays the same: that is why an application must always target the name.

Note also the shape of the command: docker compose exec site ... designates the container by the name of its service, with no need to know the full container name or its number. The commands that act on a service all work this way.

Which commands do you use every day?

Five commands come up often. They all accept a service name, and without one, they apply to the whole application. Only exec requires a service name, since it must know where to run the command.

The first, docker compose logs -f, displays the logs of the services and stays open to follow new lines. Add a service name to see only one, and --tail 3 to limit the history to the last three lines. Quit with Ctrl+C: that stops the display, not the containers.

Terminal window
docker compose logs -f --tail 3 base
base-1 | 2026-11-02 12:42:40.741 UTC [1] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
base-1 | 2026-11-02 12:42:40.748 UTC [65] LOG: database system was shut down at 2026-11-02 12:42:40 UTC
base-1 | 2026-11-02 12:42:40.753 UTC [1] LOG: database system is ready to accept connections

Each line starts with the service name and a number, which lets you tell the services apart when you follow them all together. Here, the message ready to accept connections indicates that the database is ready to receive connections.

The second, docker compose exec, runs a command in a service that is already started. Ask the database for its state with pg_isready, a tool provided by the image.

Terminal window
docker compose exec base pg_isready -U postgres
/var/run/postgresql:5432 - accepting connections

The answer accepting connections confirms that the database is ready. To get a shell in a service, run docker compose exec site sh, and leave it with exit.

The third, docker compose stop, stops the containers without removing them. They stay there, stopped, with their network and their volume, and docker compose start restarts them as they were. This pair is used to pause the application for a while. up -d, for its part, recreates the services whose file has changed.

The fourth, docker compose pull, downloads the images named in the file, without starting anything. It is used to prepare a machine before launching the application, or to check that the images exist.

Since your images are pinned by digest, pull never brings back a newer version. To change version, edit the tag and the digest in the file, then run up -d: Compose recreates the service concerned.

The fifth is docker compose config, already seen. Rerun it after every change to the file, before up -d.

What is the difference between stopping and removing an application?

docker compose down removes the containers and the network of the project, and leaves the volumes in place. docker compose down -v also removes the named volumes declared in the file. The first removes the application while keeping its data, the second destroys that data.

Try the first one, then look at what is left.

Terminal window
docker compose down
docker volume ls
[+] down 3/3
✔ Container boutique-site-1 Removed 0.3s
✔ Container boutique-base-1 Removed 0.3s
✔ Network boutique_default Removed 0.1s
DRIVER VOLUME NAME
local boutique_donnees

The containers and the network are gone, but boutique_donnees still exists. If you rerun docker compose up -d, Compose creates new containers and reattaches the existing volume: the database finds its files again.

Prove it. Restart the application, then repeat pg_isready until the answer accepting connections, which indicates that the database is ready. Then create a table that holds one row.

Terminal window
docker compose up -d
docker compose exec base pg_isready -U postgres
docker compose exec base psql -U postgres -c "CREATE TABLE clients (id int, nom text);"
docker compose exec base psql -U postgres -c "INSERT INTO clients VALUES (1, 'Camille');"

Then remove the containers, recreate them and read the row back. Repeat pg_isready again before querying the database.

Terminal window
docker compose down
docker compose up -d
docker compose exec base pg_isready -U postgres
docker compose exec base psql -U postgres -c "SELECT * FROM clients;"
id | nom
----+---------
1 | Camille
(1 row)

The containers were destroyed and recreated, and the row is still there: it lived in the volume, not in the containers. Now destroy the volume itself.

Terminal window
docker compose down -v
docker volume ls
[+] down 4/4
✔ Container boutique-base-1 Removed 0.3s
✔ Container boutique-site-1 Removed 0.2s
✔ Network boutique_default Removed 0.1s
✔ Volume boutique_donnees Removed 0.0s
DRIVER VOLUME NAME

Unlike the first down, which printed three lines, down -v prints four, including Volume boutique_donnees Removed. This time the list is empty: boutique_donnees has disappeared with its files, and no command can bring it back. The database's data is lost.

One special case, with no effect in this lesson: a volume created outside Compose and declared in the file with external: true is never removed by Compose, even with down -v.

What to do when the stack does not start?

Start by reading the message: Compose almost always points to the line or the object at fault. The table lists the cases you may meet on a first stack, from symptom to fix.

What you seeProbable causeWhat you do
no configuration file provided: not foundYou are not in the folder of the compose.yamlMove to that folder, or add -f path/compose.yaml
A YAML error that cites a lineShifted indentation, a tab, a forgotten colonFix the cited line, then rerun docker compose config
A message about a port already allocated or already in useAnother container or another program occupies port 18410Find who occupies it, as in the lesson publish a port, or change the left-hand port
The base container stops right after startingThe POSTGRES_PASSWORD password is missingAdd it under environment:, then docker compose up -d
Two sets of containers appear for the same application, often with a port-already-allocated errorAn up -d was run under another project nameSpot the extra set with docker compose ls, then remove it with docker compose -p name down -v, where name is the name of the extra set, never that of the original application (otherwise you destroy its volume). In docker compose ls, the extra set is the one whose name is not that of your folder, even if its configuration file is the same
down removed nothing, with a No resource found to remove for project warningThe project name is not the one of the original upCheck with docker compose ls, then rerun down under the right name

For a container that stops by itself, docker compose logs base gives the reason in the last lines. And docker compose ps -a also shows the stopped containers, which plain ps hides.

How do you clean everything up after the demo?

The demonstration leaves the ~/boutique folder and two images on the machine. The docker compose down -v command removes the containers, the network and the volume that are still there. Then remove the two images by their name with digest, then the folder: rm deletes the file, rmdir the folder that is now empty.

Terminal window
cd ~/boutique
docker compose down -v
docker rmi nginx:1.30.6-alpine@sha256:0e52bd054126cdc040b1276c0af347aeb02a41b9cfe305d6094767ac30e97949 \
postgres:18.6-alpine3.24@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5
cd ~ && rm boutique/compose.yaml && rmdir boutique

Each image prints two lines, Untagged: then Deleted:. After that, docker ps -a, docker volume ls and docker images show nothing of the lesson any more. A project started with -p under another name is removed the same way, with docker compose -p name down -v.

Practice

The lab gives you two ready-made images and no running service. You write the compose.yaml that describes them and you start the application under an imposed project name. The two services must reach each other by name. Only one is published on the host, at the address and the port that the statement fixes; the other stays internal.

The check looks at what runs on the machine, not at the commands you typed. It verifies that the services are started, that only the intended service is published and that the other one is not. It briefly changes the answer of the internal service and requires the published service to relay it, proof that the two reach each other by name. It recreates the two containers from your file. A container modified by hand therefore does not survive the check: only the file counts.

Key points

  1. A compose.yaml file describes the application: a services: block with one service per container, and a volumes: block for the named volumes. Compose creates by itself the network that links the services.

  2. In a service, image:, ports:, environment: and volumes: correspond to the image passed to docker run and to the -p, -e and -v options you already know.

  3. docker compose config displays the effective configuration without creating anything. Run it before up -d to check the file.

  4. The project name comes by default from the folder name. It prefixes the containers, the network and the volumes, and lets Compose find its objects. Keep the same name on every command.

  5. A service reaches another by its service name, not by its address, which can change at each recreation.

  6. Day to day: up -d starts, ps lists, logs -f follows the logs, exec runs a command in a service, stop stops without removing, start restarts, pull fetches the images.

  7. down removes the containers and the network and keeps the volumes. down -v also removes the volumes of the application, and its data with them.

Knowledge check

Check that the essentials of this lesson have stuck. The questions cover only what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn