PTENES
MODULE 4.2

🎼 Docker Compose: Multi-Service

A real application is almost never just one container: there’s the app, the database, maybe a cache. Docker Compose is the tool that orchestrates all these containers at once, with a single file and a single command.

6
Topics
45
Minutes
Basic
Level
Hands-on
Type
1

What Is Docker Compose

In the previous module, you deployed one container at a time, manually, with docker run. It works for one container. But what if your site needs three at the same time: the app, the database, and a cache? Type three huge commands every time and remember each port, volume, and connection? Forget it. The Docker Compose solves this: you describe everything in a file and bring up the entire stack with a single command.

docker-compose.yml services: app: database: cache: up -d the Compose network (containers can see each other by name) app node :3000 database postgres :5432 cache redis :6379 💾 volume (data) One file describes the entire stack. One command starts everything: docker compose up -d

🧠 Analogy: The Orchestra Conductor

Imagine an orchestra. Each musician (container) knows how to play their instrument on their own: the app plays, the database plays, the cache plays. But if everyone starts whenever they want, the result is noise. The maestro (Compose) reads the score (the file docker-compose.yml) and governs them all together: who starts first, who talks to whom, and in what tone.

  • •A sheet music = the file docker-compose.yml
  • •The musicians = each service (app, database, cache)
  • •O "start playing" = docker compose up

💡 Docker Compose is already included

If you installed modern Docker (Docker Desktop or the recent Docker Engine on Linux), Compose already installed as a subcommand: you call docker compose (with a space). On older machines, the command was docker-compose (with a hyphen). Both do the same thing; in this module, we use the newer form with a space.

# Check whether Compose is available

$ docker compose version

Docker Compose version v2.27.0

2

The Basic docker-compose.yml File

All the magic lives in a single text file called docker-compose.yml. It’s written in YAML, a simple format based on indentation (spaces at the beginning of the line). The golden rule: indentation determines what goes inside what. We’ll go from the simplest to the most complete.

The smallest possible compose file: one service

# docker-compose.yml

services:

app:

image: nginx:alpine

ports:

- "8080:80"

This describes a service called app, which uses the image nginx:alpine exposes the container’s port 80 on port 8080 of your machine.

services

The list of all containers in the stack. Each key here is a service.

image

A ready-to-use image (from Docker Hub). Alternative: build: to build from your Dockerfile.

ports

Maps ports in the format "host:container". The one on the left and the one on your PC.

💡 About the “version:” at the top

In older tutorials, you’ll see a line version: "3.8" at the beginning of the file. In modern Compose (v2), this line is optional and ignored: you can start directly at services:. If an example file has the version:, don't worry, it still works.

⚠️ Common Error

Problem: "yaml: line 4: mapping values are not allowed in this context" or the stack won't start.
Solution: Almost always, it’s incorrect indentation. YAML doesn't accept Tab, just spaces. Always use 2 spaces per level and never mix tabs and spaces. In VS Code, turn on "show whitespace" to spot the problem.

✓ What TO DO

  • ✓Always use 2 spaces per indentation level
  • ✓Using fixed versions for the images (nginx:1.27)
  • ✓Give services clear names (app, banco)

✗ What NOT to do

  • ✗Use Tab to indent (breaks YAML)
  • ✗Use only :latest in production (it becomes a surprise)
  • ✗Forget the quotation marks in "8080:80"
3

Services, Networks, and Volumes

Three concepts solve 90% of cases: services (the containers), networks (how they communicate) and volumes (where the data is stored). The great thing about Compose: it creates an automatic network for your stack, and inside it one service calls the other by name.

👁 What you’ll see: communication by name

You don’t use an IP. If the database service is called banco, the app connects to it through the URL using the service name as the address:

# Inside the app container, the database connection looks like this:

DATABASE_URL=postgres://user:senha@database:5432/meubanco

# "banco" is the service name, NOT an IP. Compose resolves it.

Network: ready to go by default

When you bring up the stack, Compose creates a network just for it. All services join that network automatically and can reach each other by name. You only declare networks manually when you want to isolate groups (e.g., separate the frontend from the backend).

services:

app:

image: my-app

depends_on:

- database # ensures the database starts first

Volume: so the data doesn’t disappear

A container is disposable: if you remove the database container, the data goes with it. The volume stores the data outside from the container, in a place that survives recreation. Declare it at the root and use it inside the service:

services:

database:

image: postgres:16

volumes:

- dados-db:/var/lib/postgresql/data

# Volume declaration at the root of the file:

volumes:

dados-db:

Format: nome-do-volume:/caminho/dentro/do/container. Even if you take down and recreate the container, the data stays there.

⚠️ Common Error

Problem: "Every time I run docker compose down my bank resets."
Solution: You’re missing the volume. Without a volume, the data lives inside the container and disappears when it's removed. Declare a named volume for the database. Also watch out for down -v: o -v deliberately deletes the volumes.

4

Bring Up a Complete Stack: App + Database

It's time to bring everything together. Let's build a real stack: a web app that communicates with a PostgreSQL database, with a volume so the data doesn't disappear. One file, one command, two containers talking to each other.

The complete docker-compose.yml

# docker-compose.yml

services:

app:

build: . # builds from the local Dockerfile

ports:

- "3000:3000"

environment:

- DATABASE_URL=postgres://user:senha@database:5432/app

depends_on:

- database

database:

image: postgres:16

environment:

- POSTGRES_USER=user

- POSTGRES_PASSWORD=senha

- POSTGRES_DB=app

volumes:

- dados-db:/var/lib/postgresql/data

volumes:

dados-db:

1

Bring up the entire stack

O -d ("detached") releases the containers running in the background

$ docker compose up -d

[+] Running 3/3

✓ Network meuapp_default Created

✓ Container meuapp-banco-1 Started

✓ Container meuapp-app-1 Started

2

Check that it went up

Lists the stack's containers and the status of each one

$ docker compose ps

NAME SERVICE STATUS PORTS

meuapp-app-1 app Up 12 seconds 0.0.0.0:3000->3000/tcp

meuapp-banco-1 banco Up 13 seconds 5432/tcp

3

Take it down when you’re done

Stop and remove the containers and network (but keep the data volume)

$ docker compose down

[+] Running 3/3

✓ Container meuapp-app-1 Removed

✓ Container meuapp-banco-1 Removed

✓ Network meuapp_default Removed

💡 Tip: run up without -d to see everything at once

The first time, run docker compose up without o -d. The logs from all services appear mixed together on the screen, live. Great for seeing if anything went wrong during startup. When everything is set, bring it down with Ctrl+C and bring it back up with -d.

5

Logs and Debugging: When Something Breaks

Did you bring up the stack and the site won’t open? Don’t worry, that’s normal. Compose gives you three tools to investigate: view the logs (what each service is saying), see the state of the containers, and go inside from a container to take a closer look.

logs - Read what the services say

# Logs for all services, following live (-f = follow)

$ docker compose logs -f

banco-1 | database system is ready to accept connections

app-1 | Server listening on port 3000

# Logs for just ONE service

$ docker compose logs app

# Only the last 50 lines

$ docker compose logs --tail 50 banco

The log is the first place to look. 9 out of 10 problems show up here in plain text.

ps - Check the status of each container

$ docker compose ps

NAME SERVICE STATUS PORTS

meuapp-app-1 app Exited (1) 4 seconds ago

meuapp-banco-1 banco Up 20 seconds 5432/tcp

Here, the app is set as Exited (1): it went down. The next step is to check its log with docker compose logs app.

🔎 exec - Enter a container

Sometimes you need to look inside: run a command, open the database client, check a file. The exec run a command inside a container that is already running:

# Open a terminal inside the app container

$ docker compose exec app sh

/app # ls

node_modules package.json server.js

# Open the PostgreSQL client inside the database

$ docker compose exec banco psql -U user -d app

app=# \dt

⚠️ Common Error

Problem: "The app starts, but can’t connect to the database: connection refused."
Solution: O depends_on ensures the order of startup, but doesn’t wait for the database to become ready to accept connections (it takes a few seconds). Simple solution: make the app try to reconnect a few times. Robust solution: use healthcheck in the database + condition: service_healthy in depends_on.

6

Updates and Rollback

Your application is live, and you want to publish a new version. Or worse: you published it and something went wrong; you need to go back fast. With Compose, updating and rolling back versions take just a few commands. The key is to pin the image tag to always know which version is running.

Update to a new version

Suppose your image in the file was meu-app:1.4 and the new version and the 1.5. You change the tag in the file and update only what changed:

# 1. Edit the compose file: image: meu-app:1.4 -> image: meu-app:1.5

# 2. Download the new image from the registry

$ docker compose pull

✓ app Pulled

# 3. Recreate only the containers that changed

$ docker compose up -d

[+] Running 2/2

✓ Container meuapp-banco-1 Running

✓ Container meuapp-app-1 Recreated

Notice: the database stays Running (you didn’t change it) and only the app is Recreated. Compose only touches what changed.

↻

Rollback: return to the previous version

Did things go wrong in 1.5? Move the tag back to 1.4 and upload it again. Since you pinned the version, you know exactly where to go back to.

# Edit the compose file back: image: meu-app:1.5 -> image: meu-app:1.4

$ docker compose up -d

✓ Container meuapp-app-1 Recreated

# Ready: in seconds, you're back on version 1.4

✓ What TO DO

  • ✓Pin version tags (app:1.4) so you can go back
  • ✓Run pull before the up -d
  • ✓Store the compose file in Git (each version becomes history)

✗ What NOT to do

  • ✗Use :latest (you can’t tell which version to roll back to)
  • ✗Run down -v thinking all you have to do is restart
  • ✗Update directly in production without testing first

🏆 You can now run a real stack

With the app, database, and volume in one file, starting everything with one command, reading logs, accessing containers, and rolling back a version when needed, you already have the essentials of local orchestration. In the next module, you'll learn how to build this stack start on its own when the server starts, with the systemd.

📚 Module Summary

✓
Compose is the conductor - manages multiple containers with one file and one command
✓
docker-compose.yml - YAML with space-based indentation; starts with services:
✓
Services, networks, and volumes - talk by name; volumes store the data
✓
up -d / down / ps / logs / exec - start, stop, check status, read logs, connect
✓
pull + up -d and roll back to the tag - update and roll back by pinning versions

Next Module:

4.3 - Systemd: making your stack start automatically when the server boots and restart if it goes down