# Docker Compose basics: run multi-container apps on a Linux server

> Learn Docker Compose on a Linux server: compose.yaml, .env variables, healthchecks, restart policies, profiles, safe updates, backups and multiple stacks.

Difficulty: Beginner\
Tested on: Ubuntu 24.04 LTS, Ubuntu 26.04 LTS, Debian 12, Debian 13

Docker Compose describes a whole application, with its containers, networks, volumes and settings, in one YAML file and starts it with one command. Almost every app guide in this library ships a Compose file, so learning the format once pays off everywhere. This guide builds a small but realistic stack on a Linux server: a web service, a PostgreSQL database with a healthcheck, and an optional admin tool behind a profile. Along the way you keep secrets in a `.env` file, publish ports only on `127.0.0.1`, and learn how to update, back up and restore a Compose project and how to run several projects on the same server.

## Prerequisites

- A server running **Ubuntu 24.04 LTS**, **Ubuntu 26.04 LTS**, **Debian 12** or **Debian 13**.
- A non-root user with `sudo` rights and SSH key login. See [Secure a new Linux server](/guides/secure-a-new-linux-server) and [Set up SSH keys](/guides/ssh-keys).
- Docker Engine with the Compose plugin from Docker's repository: [Install Docker on Ubuntu](/guides/install-docker-ubuntu) or [Install Docker on Debian](/guides/install-docker-debian). Check it with `docker compose version`; any v2 or v5 release works.

Compose itself is a small CLI plugin, and Docker does not publish hardware requirements for it. What you need depends on the containers you run. For the example stack in this guide (nginx and PostgreSQL), the following is a conservative starting point, not an official figure:

| Resource | Minimum (official) | Suggested starting point |
|---|---|---|
| CPU | Not published | 1 vCPU |
| RAM | Not published | 1 GB |
| Disk | Not published | 10 GB free for images, volumes and backups |

## Step 1 — Create a project directory

A Compose **project** is one folder with a `compose.yaml` file in it. By default the project name is the folder name, and Compose prefixes everything it creates with it: the network becomes `demo_default`, the volume `demo_db_data`, the containers `demo-db-1` and so on. Project names may contain only lowercase letters, digits, dashes and underscores.

Following the convention used across this library, create the project under `/opt` and give your user ownership:

```bash
sudo mkdir -p /opt/demo
sudo chown $USER:$USER /opt/demo
cd /opt/demo
```

Run all `docker compose` commands from this folder. You can also run them from anywhere with `docker compose --project-directory /opt/demo …`, but staying in the folder is simpler.

## Step 2 — Put settings and secrets in .env

Compose automatically reads a file named `.env` in the project directory and substitutes its values into `compose.yaml` wherever you write `${VARIABLE}`. This keeps passwords and per-server settings out of the Compose file, so you can share or version the file without leaking secrets.

Generate a strong database password and write the `.env` file:

```bash
openssl rand -hex 32
```

```env
# /opt/demo/.env
POSTGRES_USER=demo
POSTGRES_PASSWORD=paste-the-generated-value-here
POSTGRES_DB=demo
WEB_PORT=8080
```

Then make the file readable only by you:

```bash
chmod 600 /opt/demo/.env
```

The interpolation syntax supports defaults and required values:

| Syntax | Result |
|---|---|
| `${VAR}` | Value of `VAR`, or an empty string with a warning if it is unset |
| `${VAR:-default}` | `default` if `VAR` is unset or empty |
| `${VAR:?message}` | Stops with `message` if `VAR` is unset or empty |
| `$$` | A literal `$` that Compose passes through to the container |

Variables exported in your shell override `.env`, and `--env-file` replaces it with another file. The `.env` file is only for interpolation; to hand variables to a container you still list them under `environment:` (or point `env_file:` at a file).

> **Note**
>
> Docker's documentation advises against passing highly sensitive values as environment variables at all and recommends Compose secrets for them. For the typical self-hosted app, a `.env` file with mode `600` is the common baseline; use secrets where the app's image supports reading passwords from files.

## Step 3 — Write compose.yaml

Create `/opt/demo/compose.yaml`. The `web` service stands in for your application, `db` is a PostgreSQL database, and `adminer` is a small database admin tool that only starts when you ask for it:

```yaml
services:
  web:
    image: nginx:stable
    restart: unless-stopped
    ports:
      - "127.0.0.1:${WEB_PORT:-8080}:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - db_data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  adminer:
    image: adminer
    profiles: ["tools"]
    ports:
      - "127.0.0.1:8081:8080"
    depends_on:
      db:
        condition: service_healthy

volumes:
  db_data:
```

Create a test page for `web`:

```bash
mkdir -p /opt/demo/html
echo "Hello from Compose" > /opt/demo/html/index.html
```

What each part does:

- **`services`** — one entry per container. `image` names the image to pull; Compose pulls it the first time you start the stack.
- **`volumes` (top level)** — declares the named volume `db_data`, which Docker stores under `/var/lib/docker/volumes/`. It survives container recreation. The `./html` entry under `web` is a **bind mount**: a host folder mapped into the container, read-only here (`:ro`).
- **Networks** — you did not declare any, so Compose creates one network, `demo_default`, and attaches every service. Each service is reachable from the others by its service name: an app would connect to the database at host `db`, port `5432`. Only use the published host port from outside the network.
- **`postgres:18` and its volume path** — from PostgreSQL 18, the official image stores data under `/var/lib/postgresql`, so the volume is mounted there. For PostgreSQL 17 and older the path is `/var/lib/postgresql/data`; always check the image's documentation.

There is no `version:` line. That element is obsolete; Compose ignores it and prints a warning if it is present.

## Step 4 — Start services in the right order with healthchecks

Plain `depends_on: [db]` only waits until the database **container** has started, not until PostgreSQL accepts connections. The long form with `condition: service_healthy` waits until the dependency's healthcheck passes:

- The `healthcheck` on `db` runs `pg_isready` inside the container every 10 seconds. `start_period` gives PostgreSQL 30 seconds to initialise before failures count, and after 5 failed checks the container is marked `unhealthy`.
- `$${POSTGRES_USER}` is escaped with `$$`, so Compose does not substitute it; the shell inside the container expands it from the container's own environment.
- The other conditions are `service_started` (the default) and `service_completed_successfully`, which waits for a one-off job such as a migration to exit with code 0.

Healthchecks also show up in `docker compose ps`, which makes them useful even without dependencies.

## Step 5 — Choose restart policies and keep ports private

A **restart policy** decides what happens when a container exits or the server reboots:

| Policy | Behaviour |
|---|---|
| `no` | Default. Never restarted automatically |
| `always` | Always restarted, also after a reboot |
| `on-failure[:N]` | Restarted only after a non-zero exit code, optionally at most N times |
| `unless-stopped` | Like `always`, but stays stopped after you stop it yourself |

`unless-stopped` is a good default for long-running services. The `adminer` tool has no policy, so it stays down after a reboot.

Every published port in the example starts with `127.0.0.1`. Docker's documentation states that publishing container ports is insecure by default and that published ports bypass ufw, because Docker routes the traffic before ufw's rules are checked. A port written as `"8080:80"` is open to the internet even when ufw blocks 8080. With `127.0.0.1`, only the server itself can connect, and a reverse proxy publishes the app over HTTPS: see [Caddy](/guides/caddy-reverse-proxy), [Nginx with Certbot](/guides/nginx-reverse-proxy-certbot) or [Traefik](/guides/traefik-reverse-proxy). Database ports such as `5432` need no `ports:` entry at all, because other services reach them over the project network.

> **Tip**
>
> Always quote port mappings in YAML (`"127.0.0.1:8080:80"`). Unquoted values in the form `xx:yy` can be misread as numbers in base 60.

## Step 6 — Check, start and watch the stack

Render the final configuration with all variables substituted. Errors in the YAML or missing variables appear here before anything starts:

```bash
docker compose config
```

Start the stack in the background, then list its containers:

```bash
docker compose up -d
docker compose ps
```

`up -d` pulls missing images, creates the network and volume, starts `db`, waits until it is healthy and then starts `web`. In `docker compose ps`, the `db` row should show `(healthy)` in the status column and `web` should show `127.0.0.1:8080->80/tcp`. Test the web service from the server:

```bash
curl -I http://127.0.0.1:8080
```

You should see `HTTP/1.1 200 OK`. Follow the logs of all services, or of one service with the last 100 lines, and stop following with `Ctrl+C`:

```bash
docker compose logs -f
docker compose logs -f --tail 100 db
```

## Step 7 — Use profiles for optional services

Services with a `profiles:` entry start only when their profile is enabled. Services without profiles always start. Start the stack with the `tools` profile to get Adminer:

```bash
docker compose --profile tools up -d
```

Adminer listens on `127.0.0.1:8081`, so open it through an SSH tunnel from your own computer:

```bash
ssh -L 8081:127.0.0.1:8081 your-user@203.0.113.10
```

Then browse to `http://localhost:8081`, choose PostgreSQL, use `db` as the server and log in with the values from `.env`. When you are done, stop and remove only the tool:

```bash
docker compose --profile tools stop adminer
docker compose --profile tools rm -f adminer
```

You can also enable profiles with the `COMPOSE_PROFILES` variable, for example `COMPOSE_PROFILES=tools` in `.env`. Note that a plain `docker compose down` only removes services without a profile; add `--profile tools` to include profiled ones.

## Step 8 — Everyday commands and what down -v deletes

| Command | What it does |
|---|---|
| `docker compose up -d` | Creates or updates the stack; recreates only containers whose config or image changed |
| `docker compose stop` / `start` | Stops or starts containers without removing them |
| `docker compose restart web` | Restarts a container; does **not** apply changes to `compose.yaml` |
| `docker compose exec db psql -U demo` | Runs a command in a running container |
| `docker compose down` | Removes containers and the project network; keeps volumes |
| `docker compose down -v` | Also deletes named and anonymous volumes |
| `docker compose ls` | Lists Compose projects running on this server |

After editing `compose.yaml` or `.env`, run `docker compose up -d` again, not `restart`. Compose compares the new configuration with the running containers and recreates only what changed, keeping the data in volumes.

> **Danger**
>
> `docker compose down -v` deletes the named volumes declared in `compose.yaml`, which here means the whole PostgreSQL database. Use it only for a stack you want to wipe, and only after taking a backup. Bind-mounted host folders such as `./html` and volumes marked `external: true` are never removed by `down`.

## Step 9 — Run several projects on one server

Compose isolates projects by name, so many stacks can share one server:

- **One directory per project** (`/opt/n8n`, `/opt/uptime-kuma`, …). Each gets its own network and volume names automatically.
- **Unique host ports**: two projects cannot both publish `127.0.0.1:8080`. Keep a short list of which app uses which local port.
- **Avoid `container_name:`** unless an app's official file requires it. Container names must be unique on the whole server, while Compose's generated names (`project-service-1`) never collide.
- **One reverse proxy** for all apps on ports 80 and 443. With Caddy or Nginx on the host, each app gets a site block that points at its `127.0.0.1` port. With [Traefik](/guides/traefik-reverse-proxy) or [Nginx Proxy Manager](/guides/nginx-proxy-manager) in a container, the apps join a shared external Docker network instead of publishing ports.

To join an existing network that another project created, declare it as external; Compose then uses it instead of creating its own:

```yaml
networks:
  proxy:
    name: proxy
    external: true
```

The network must exist first (`docker network create proxy`); otherwise `up` stops with an error saying the external network could not be found.

## Update a Compose stack

Read the release notes of each app before updating, take a backup (next section), then pull the new images and recreate the containers that changed:

```bash
cd /opt/demo
docker compose pull
docker compose up -d
docker compose ps
docker image prune
```

`pull` downloads newer images for the tags in `compose.yaml`; `up -d` replaces only the containers whose image changed, and volumes stay attached. `docker image prune` removes the old, now unused image layers. A tag such as `postgres:18` follows patch releases of that major version. Moving to a new major version (for example PostgreSQL 18 to 19) is a deliberate change of the tag and usually needs the app's own upgrade procedure, so never jump major versions of a database by editing the tag alone.

## Back up and restore

Images can be pulled again; what needs a backup is the state:

- `compose.yaml`, `.env` and any bind-mounted folders (here `./html`) — all inside `/opt/demo`,
- named volumes (here `db_data`),
- for databases, a logical dump made with the database's own tool, which is consistent while the database runs.

Create the backup folder once, then dump the database and archive the project folder:

```bash
sudo mkdir -p /opt/backups
sudo chown $USER:$USER /opt/backups
cd /opt/demo
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > /opt/backups/demo-db-$(date +%F).sql
tar czf /opt/backups/demo-files-$(date +%F).tar.gz -C /opt demo
```

To copy a named volume at file level, which is the method for volumes without a database and a useful second copy of database files, stop the stack so nothing writes to it and archive the volume with a short-lived container, as shown in [Install Docker on Ubuntu](/guides/install-docker-ubuntu):

```bash
docker compose stop
docker run --rm -v demo_db_data:/data -v /opt/backups:/backup ubuntu tar czf /backup/demo_db_data-$(date +%F).tar.gz -C /data .
docker compose start
```

To restore the database on a fresh server, unpack the project folder, start only the database and wait until it is healthy, then load the dump into the new, empty database:

```bash
tar xzf /opt/backups/demo-files-2026-10-09.tar.gz -C /opt
cd /opt/demo
docker compose up -d --wait db
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < /opt/backups/demo-db-2026-10-09.sql
docker compose up -d
```

Copy backups off the server as well, for example with `rsync` or `scp` to another machine, so a lost server does not mean lost data.

## Troubleshooting

### The "POSTGRES_USER" variable is not set. Defaulting to a blank string.

Compose did not find the variable. Either `.env` is missing, is not in the project directory, or you ran the command from a different folder. Run `cd /opt/demo` and check the output of `docker compose config`. With the `:?` syntax used for `POSTGRES_PASSWORD`, Compose stops with `required variable POSTGRES_PASSWORD is missing a value` instead of starting a database without a password.

### dependency failed to start: container demo-db-1 is unhealthy

The healthcheck of `db` failed 5 times. Read the database log with `docker compose logs db`. Common causes are a wrong user or database name in the healthcheck, an empty password, or a volume mounted at the wrong path for the PostgreSQL major version. Use `docker inspect demo-db-1` and look at the `Health` section for the output of the last checks.

### Bind for 127.0.0.1:8080 failed: port is already allocated

Another container or service already uses that host port. Find it with `sudo ss -tlpn 'sport = :8080'` or `docker ps`, then change `WEB_PORT` in `.env` and run `docker compose up -d` again.

### My changes to compose.yaml or .env do nothing

`docker compose restart` restarts the existing containers with their old configuration. Run `docker compose up -d` so Compose recreates the changed services. If an image tag stayed the same but a newer image exists, run `docker compose pull` first.

### the attribute `version` is obsolete, it will be ignored

The file contains a top-level `version:` line from an older Compose format. Delete the line; nothing else changes.

### A container port is reachable from the internet although ufw blocks it

The port is published without a host address, for example `"8080:80"`. Change it to `"127.0.0.1:8080:80"` and run `docker compose up -d`. Check from another machine with `nc -vz your-server-ip 8080`; the connection should fail.

## Next steps

- Put your apps behind HTTPS with [Caddy as a reverse proxy](/guides/caddy-reverse-proxy).
- Route containers by labels with [Traefik](/guides/traefik-reverse-proxy).
- Manage stacks from a browser with [Portainer](/guides/install-portainer).
- Compare servers for container workloads on the [Docker hosting](/docker-hosting) page.
- Read the full [Compose file reference](https://docs.docker.com/reference/compose-file/services/) for every service option.

## Frequently asked questions

### What is the difference between docker-compose and docker compose?

docker-compose with a hyphen is the original Compose v1, written in Python and no longer supported. docker compose with a space is the Go plugin (Compose v2, now released as v5) that ships with Docker Engine as docker-compose-plugin. Every guide in this library uses docker compose.

### Should I name the file compose.yaml or docker-compose.yml?

Both work. compose.yaml is the canonical name and wins if both files exist in the same folder; docker-compose.yml is still accepted for backwards compatibility. Keep whatever name a project’s official instructions use.

### Do I still need the version line at the top of the file?

No. The top-level version element is obsolete. Compose ignores it, validates the file against the latest Compose Specification and prints a warning, so you can delete the line.

### Does docker compose down delete my data?

Plain down removes the project’s containers and networks but keeps named volumes and bind-mounted folders. down -v also deletes the named volumes declared in the file and anonymous volumes, which usually means your databases. External volumes are never removed.

### Can I run several Compose projects on one server?

Yes. Give each project its own directory, which gives it its own project name, network and volume names. Publish each app on a different 127.0.0.1 port and put one reverse proxy in front of all of them.

---

Source: <https://hyperdc.com/guides/tutorials/docker-compose-basics>\
Updated: 2026-10-09
