# Docker Compose Tutorial: Run Multi-Container Apps with One File

> Learn Docker Compose with a real stack: a Node.js app, PostgreSQL and Redis with healthchecks, volumes, environment files, profiles and the everyday commands.

- Source: https://www.itwonderlab.com/docker-compose-tutorial/
- Published: 2026-10-06
- Updated: 2026-10-06
- Author: Javier Ruiz Jiménez (https://www.javierruizjimenez.com/)
- Site: IT Wonder Lab (https://www.itwonderlab.com/)

---

## What is Docker Compose

**Docker Compose** describes a multi-container application in one YAML file and manages it with one command. Instead of typing several `docker network create`, `docker volume create` and long `docker run` commands in the right order, you write what you want and run `docker compose up`.

Compose comes with Docker and with [Rancher Desktop](https://www.itwonderlab.com/rancher-desktop/) when you use the dockerd engine. With containerd, `nerdctl compose` reads the same file.

![Docker Compose application: one compose.yaml defines a web app, a PostgreSQL database with a named volume and a Redis cache on the default network, started with docker compose up](https://www.itwonderlab.com/media/tutorials/Diagrams/ITWL-Docker-Compose-App.svg "One file, three services, one network and one volume")

## The project

We reuse the Node.js application of the [Dockerfile tutorial](https://www.itwonderlab.com/dockerfile-tutorial/) and add a database and a cache. Update `server.js` to count visits in Redis and store them in PostgreSQL if you wish; for this tutorial the infrastructure is what matters, so the application only needs to read two environment variables: `DATABASE_URL` and `REDIS_URL`.

```text
myapp/
  compose.yaml
  Dockerfile
  server.js
  package.json
  .env
```

## compose.yaml

The file is named `compose.yaml` (or `compose.yml`, or the older `docker-compose.yml`). The top-level `version:` key is obsolete: do not write it.

```yaml title="compose.yaml"
services:
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started
    restart: unless-stopped

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

  cache:
    image: redis:7-alpine

volumes:
  db-data:
```

```text title=".env"
DB_PASSWORD=change-me
```

Compose reads `.env` automatically and substitutes `${DB_PASSWORD}`. Add `.env` to `.gitignore`.

What each part does:

- **`build: .`** builds the image from the Dockerfile in this directory. Use `image:` to pull a prebuilt one.
- **Networking**: Compose creates a network for the project and every service joins it, reachable by its **service name**. The app connects to `db:5432` and `cache:6379`. Only `app` publishes a port.
- **`depends_on` with `condition: service_healthy`** waits until the healthcheck of `db` passes, not just until the container has started. Without it your app often starts before the database accepts connections. The doubled `$$` stops Compose from interpolating the variable, so the shell inside the container expands it.
- **`volumes`**: the named volume `db-data` keeps the database between runs.

## The everyday commands

```shell
$ docker compose up -d --build      # create, build if needed and start in the background
$ docker compose ps
NAME            IMAGE         SERVICE   STATUS                    PORTS
myapp-app-1     myapp-app     app       Up 20 seconds             0.0.0.0:3000->3000/tcp
myapp-cache-1   redis:7...    cache     Up 25 seconds             6379/tcp
myapp-db-1      postgres:17   db        Up 25 seconds (healthy)   5432/tcp
$ docker compose logs -f app
$ docker compose exec db psql -U app -d app
$ docker compose stop               # stop, keep the containers
$ docker compose down               # remove containers and networks
$ docker compose down -v            # ...and volumes (deletes the data!)
```

After editing the Dockerfile or the code, `docker compose up -d --build` rebuilds and recreates only the changed services. To validate the file and see the final configuration with all variables substituted:

```shell
$ docker compose config
```

## Configuration: environment and secrets

- `environment:` sets values inline. `env_file: .env.app` loads a whole file.
- Variables like `${DB_PASSWORD}` come from your shell or the `.env` file. Use `${VAR:-default}` for a default and `${VAR:?error message}` to fail if it is missing.
- For real secrets prefer Compose **secrets**, which mount a file under `/run/secrets/`:

```yaml
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./db_password.txt
```

## Profiles: optional services

Services with `profiles` start only when you ask for them, which keeps the default stack small:

```yaml
services:
  adminer:
    image: adminer
    ports:
      - "8081:8080"
    profiles: ["debug"]
```

```shell
$ docker compose up -d                    # without adminer
$ docker compose --profile debug up -d    # with adminer
```

## Override files: one base, many environments

Compose merges `compose.yaml` with `compose.override.yaml` automatically. Keep the production-like definition in the first and your development tweaks (bind mounts, ports, debug) in the second. Use `-f` to choose files explicitly:

```shell
$ docker compose -f compose.yaml -f compose.prod.yaml up -d
```

## Scale and limit

```shell
$ docker compose up -d --scale app=3   # needs no fixed host port on the service
```

```yaml
services:
  app:
    deploy:
      resources:
        limits:
          cpus: "0.50"
          memory: 256M
```

## Compose in production?

Compose is excellent for development, CI and single-host deployments. To run containers on a cluster use Kubernetes ([Kubernetes in Rancher Desktop](https://www.itwonderlab.com/rancher-desktop-kubernetes/), [EKS](https://www.itwonderlab.com/terraform-eks/)) or a managed service such as [ECS with Fargate](https://www.itwonderlab.com/containers-aws-ecs-terraform-fargate/). The `docker compose config` output and tools such as Kompose can help you migrate.

## Next steps

Edit code and see it running without rebuilding by hand: [Docker Compose Watch](https://www.itwonderlab.com/docker-compose-watch/).
