![Docker Compose: your first multi-container app](https://cdn.slatesource.com/f/e/a/fea9f9c3-961e-47ec-9dd5-fb406589d2bb.webp)

# Docker Compose: your first multi-container app

- [Made in Slatesource](https://slatesource.com/@steph/docker-compose-your-first-multi-container-app)
- By [Steph](https://slatesource.com/@steph)
- Created on Oct 13, 2026

## Most Docker Compose tutorials are quietly wrong

Every tutorial from before 2023 tells you to put a \`version: '3'\` line at the top of your file and to run \`docker-compose\` with a hyphen. Both are wrong today. The \`version:\` key is deprecated under the modern Compose Specification and earns you a warning. The hyphenated \`docker-compose\` command is a retired Python binary. The current tool is \`docker compose\` (with a space), a plugin baked into Docker itself. Those two stale habits are the surface problems. The deeper one is startup order. Most tutorials show \`depends\_on: db\` and leave it there. That only tells Docker to start the database container before the app container. It does not wait for Postgres to actually accept connections. Your app crashes on first boot, you add a \`sleep 5\` hack, and the tutorial moves on. This tutorial does it properly.

![](https://cdn.slatesource.com/f/e/a/fea9f9c3-961e-47ec-9dd5-fb406589d2bb.webp)

Photo by Bob Richards (CC0). Stacked shipping containers.

Time needed

About 30 minutes

Cost

Free (Docker Desktop is free for personal use)

Difficulty

Beginner, no prior Docker experience required

What you will have

A web app and a Postgres database running together

## Before you start: install Docker Desktop

Docker Desktop bundles the Docker daemon, the Docker CLI, and the \`docker compose\` plugin in one installer. Download it for your OS from the official site below. Once installed, open a terminal and run the check command. If you see a version number, you are ready.

bash

1

```
docker compose version
```

[Docker Docs: how to install Docker and the Compose plugin on Windows, macOS, and Linux](https://docs.docker.com/compose/install/?utm_source=slatesource)

## Step 1. Create your project folder

Make a new folder for your project and move into it. Everything in this tutorial lives in this folder.

bash

1

```
mkdir my-app && cd my-app
```

## Step 2. Write your compose.yaml

Create a file called \`compose.yaml\` in the project folder. This is the modern filename. Compose still reads \`docker-compose.yml\` for backwards compatibility, but \`compose.yaml\` is the current convention. Paste this example. It defines two services: a Postgres database and a simple Node.js web app. Read the comments before running anything.

yaml

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

```
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: myapp
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d myapp"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

  web:
    image: node:20-slim
    working_dir: /app
    volumes:
      - .:/app
    command: node server.js
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://appuser:${DB_PASSWORD}@db:5432/myapp
    depends_on:
      db:
        condition: service_healthy

volumes:
  db_data:
```

## What each part does

The \`db\` service pulls the official Postgres 16 image. The \`volumes:\` block mounts a named volume called \`db\_data\` so your database survives a \`docker compose down\`. The \`healthcheck:\` block runs \`pg\_isready\` every five seconds. Only when that check passes does Compose allow the \`web\` service to start. The \`web\` service sets \`DATABASE\_URL\` using \`db\` as the hostname, not \`localhost\`. Inside a Docker network, services reach each other by their service name. Using \`localhost\` here is the second most common beginner mistake: it resolves to the web container itself, not the database. The \`depends\_on:\` block with \`condition: service\_healthy\` is the correct way to sequence startup. Without it, Node tries to connect before Postgres has finished initialising its data directory and the connection fails.

[Docker Docs: controlling service startup order with depends_on and health checks](https://docs.docker.com/compose/how-tos/startup-order/?utm_source=slatesource)

## Step 3. Create your .env file

Never put passwords directly in \`compose.yaml\`. Compose reads a \`.env\` file in the same folder automatically. Create one now with your database password.

plaintext

1

```
DB_PASSWORD=change-this-to-something-strong
```

## Step 4. Add .env to .gitignore

If you are using Git, add \`.env\` to your \`.gitignore\` immediately. Committing credentials is the fastest way to leak them.

bash

1

```
echo ".env" >> .gitignore
```

## Step 5. Start everything

Run the following command from your project folder. The \`-d\` flag runs containers in the background. Compose pulls the images on first run, which takes a minute.

bash

1

```
docker compose up -d
```

## Step 5 continued. Watch startup order in action

Watch the logs to see the health check in action. You will see Postgres initialise its data directory, then the health check pass, and then the web service start. This is what \`service\_healthy\` buys you.

bash

1

```
docker compose logs -f
```

## Step 6. Check the services are running

The \`ps\` command shows you every container Compose started and its current status. A healthy database shows \`(healthy)\` next to the container name.

bash

1

```
docker compose ps
```

## Step 7. Bring everything down

When you are done, stop and remove the containers. Your database data is safe in the \`db\_data\` named volume and survives this command. Run \`docker volume prune\` separately if you want to delete it.

bash

1

```
docker compose down
```

Old mistake 1

version: at the top. Deprecated. Delete it.

Old mistake 2

docker-compose with a hyphen. Use docker compose.

Old mistake 3

depends\_on: db with no condition. Does not wait for readiness.

Old mistake 4

localhost as the DB host. Use the service name: db.

Old mistake 5

Password in compose.yaml. Use a .env file instead.

The \`docker system prune\` command does not remove named volumes by default. If you ever need to free disk space from old volumes, run \`docker volume prune\` or \`docker system prune --volumes\` explicitly. On a machine that has been running Compose stacks for a while, orphaned volumes can take several gigabytes.

[Docker Docs: full depends_on reference with all three condition types](https://docs.docker.com/reference/compose-file/services/?utm_source=slatesource#depends_on)