# Install Docker Compose on Ubuntu 22.04, 24.04, 26.04

> Get Docker Compose v2 on an Ubuntu server, move off the old docker-compose, and run an app with PostgreSQL, health checks, an .env file and safe ports.

Docker Compose lets you describe several containers, their settings, storage and network in one YAML file and manage them as a single project. On a VPS that is the usual way to run self hosted apps: one folder per app, one `compose.yaml` inside, and one command to start, stop or update it.

This guide has two parts. First you make sure the current Compose (the `docker compose` plugin) is installed, whatever state the server is in. Then you build a real project: an application with a PostgreSQL database, secrets in an `.env` file, health checks, persistent volumes and a port that only a reverse proxy can reach.

## What you need

- **An Ubuntu 22.04, 24.04 or 26.04 VPS with Docker Engine.** If Docker is not installed yet, follow [install Docker on Ubuntu](https://datapasa.com/wiki/server-management/install-docker-on-ubuntu) first. It installs Compose along the way.
- **SSH access** with sudo rights, ideally as a user in the `docker` group so you can skip `sudo` before every command.
- For the example project, **VPS-1** (1 vCPU, 1 GB RAM) is enough. If you plan to run several such stacks side by side, take **VPS-2** (2 GB) or **VPS-3** (4 GB). See the [pricing page](https://datapasa.com/en/pricing).

## Check which Compose you have

Run both commands. They tell you which situation you are in:

```bash
docker compose version
docker-compose version
```

| What you see | Your situation | What to do |
|---|---|---|
| `docker compose version` prints a version | Compose v2 is installed | Nothing, skip to [the example project](#example-project-app-and-postgresql) |
| `docker: 'compose' is not a docker command` | Docker is there, the plugin is not | See the next two sections |
| Only `docker-compose version` works, showing 1.x | The old Python based Compose v1 | See [Migrate from docker-compose v1](#migrate-from-docker-compose-v1) |

Current releases of the plugin report version 2.x or 5.x. Compose v5 (released in 2025) works the same way as v2. The jump in numbering only avoids confusion with the old `version: "3"` file formats.

To see where your Docker came from, check which packages are installed:

```bash
dpkg -l | grep -E 'docker|containerd' | awk '{print $2, $3}'
```

Names like `docker-ce` and `containerd.io` mean Docker's own repository. `docker.io` and `containerd` without the suffix mean Ubuntu's packages.

## Docker from Docker's repository

This is the setup from our [install guide](https://datapasa.com/wiki/server-management/install-docker-on-ubuntu). Compose is one package away:

```bash
sudo apt update
sudo apt install docker-compose-plugin
docker compose version
```

It updates together with Docker on every `apt upgrade`.

## Docker from Ubuntu's docker.io package

If Docker was installed with `apt install docker.io`, Docker's `docker-compose-plugin` is not available, because Docker's repository is not configured. You have two options.

**Option 1. Use Ubuntu's Compose package.** Ubuntu packages Compose v2 as `docker-compose-v2` in the `universe` component for 22.04 (through `jammy-updates`), 24.04 and 26.04:

```bash
sudo apt update
sudo apt install docker-compose-v2
docker compose version
```

This is the smallest change. Like `docker.io`, it follows Ubuntu's update schedule and is usually several releases behind Docker's own builds.

**Option 2. Switch to Docker's packages.** This is what we recommend for a server you plan to keep. Remove Ubuntu's packages and install Docker's as described in [install Docker on Ubuntu](https://datapasa.com/wiki/server-management/install-docker-on-ubuntu#step-2-remove-conflicting-packages). Removing `docker.io` does not delete `/var/lib/docker`, so existing images, containers and volumes are still there after the switch. Stop your projects with `docker compose down` (without `-v`) before you start, and bring them up again afterwards.

### Installing the plugin by hand

On servers where you cannot add repositories, you can download the plugin binary from GitHub. This command installs it for all users:

```bash
sudo mkdir -p /usr/local/lib/docker/cli-plugins
sudo curl -SL "https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$(uname -m)" -o /usr/local/lib/docker/cli-plugins/docker-compose
sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
docker compose version
```

`uname -m` prints `x86_64` or `aarch64`, which match the file names on the release page. A binary installed this way never updates itself. You have to repeat the download for every new version, which is why the apt options above are better.

## Migrate from docker-compose v1

The original `docker-compose` was a Python program. It has not received updates since mid 2023 and does not understand newer Compose file features. Your existing `docker-compose.yml` files keep working with v2, so the migration is mostly about the command name.

**1. Install the v2 plugin** using one of the sections above.

**2. Remove v1.** Depending on how it was installed:

```bash
sudo apt remove docker-compose
sudo rm -f /usr/local/bin/docker-compose
pip3 show docker-compose
```

If `pip3 show` finds it, remove it with `pip3 uninstall docker-compose` as the user that installed it.

**3. Recreate the containers** of each project once with the new command:

```bash
cd /path/to/project
docker compose up -d
```

What changes and what to watch for:

| Topic | v1 (`docker-compose`) | v2 (`docker compose`) |
|---|---|---|
| Command | `docker-compose up` | `docker compose up` |
| Container names | `project_web_1` | `project-web-1` |
| `version:` at the top of the file | Required by older formats | Ignored, you can delete the line |
| File name | `docker-compose.yml` | `compose.yaml` preferred, the old name still works |

The container name change matters if scripts, cron jobs or monitoring refer to containers by name. Use `docker compose exec web ...` inside the project folder instead of hard coded names, and it works regardless of the naming scheme. Before and after the first `up`, compare `docker volume ls`. Your project's volumes should keep their names, and if a new, empty volume appears instead, stop and check before you add data to it.

Scripts that call `docker-compose` can be updated with `sed`, or you can keep them working for a while with a tiny wrapper:

```bash
printf '#!/bin/sh\nexec docker compose "$@"\n' | sudo tee /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
```

## Example project: app and PostgreSQL

The example is [Miniflux](https://miniflux.app), a lightweight RSS reader that stores its data in PostgreSQL. The structure is the same for most web apps that need a database, so you can reuse it.

What the setup does:

- Keeps passwords in an `.env` file, not in the YAML.
- Stores the database in a named volume, so it survives container updates.
- Starts the app only after PostgreSQL reports it is ready.
- Restarts both containers after a crash or a server reboot.
- Publishes the app on `127.0.0.1` only, for a reverse proxy on the same server.

### Create the project folder

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

The folder name becomes the Compose project name (`miniflux`), which prefixes container, network and volume names.

### Write the .env file

Generate random passwords. Hex strings are safe to use inside a database URL, while characters like `@`, `/` or `:` would break it:

```bash
cat > .env <<EOF
POSTGRES_PASSWORD=$(openssl rand -hex 24)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=$(openssl rand -hex 12)
EOF
chmod 600 .env
cat .env
```

Write down `ADMIN_PASSWORD`, you need it for the first login. Compose reads `.env` from the project folder automatically and substitutes the values wherever `compose.yaml` says `${NAME}`. If the folder is a git repository, add `.env` to `.gitignore`.

### Write compose.yaml

```bash
nano compose.yaml
```

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

  app:
    image: miniflux/miniflux:latest
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
        restart: true
    environment:
      DATABASE_URL: postgres://miniflux:${POSTGRES_PASSWORD}@db/miniflux?sslmode=disable
      RUN_MIGRATIONS: "1"
      CREATE_ADMIN: "1"
      ADMIN_USERNAME: ${ADMIN_USERNAME}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}
    ports:
      - "127.0.0.1:8080:8080"
    healthcheck:
      test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
      interval: 30s
      timeout: 5s
      retries: 3

volumes:
  db-data:
```

The parts that matter:

- **`${POSTGRES_PASSWORD:?...}`** stops Compose with an error if the variable is empty, instead of starting a database with no password.
- **`db-data:/var/lib/postgresql`** is the right mount point for the `postgres:18` image and newer. For PostgreSQL 17 and older images the path is `/var/lib/postgresql/data`. A wrong path means the data lives inside the container and is lost when it is recreated.
- **`healthcheck`** on `db` runs `pg_isready` every 10 seconds. The container becomes `healthy` only when PostgreSQL accepts connections.
- **`depends_on` with `condition: service_healthy`** makes Compose wait for that state before it starts `app`. `restart: true` restarts the app when Compose restarts the database, for example after you change its settings.
- **`@db`** in `DATABASE_URL` is the service name. Compose puts both containers on a private network where each service is reachable by its name. The database has no `ports:` section, so it is not reachable from outside at all.
- **`127.0.0.1:8080:8080`** publishes the app on the loopback interface only. Docker's port rules bypass UFW, so binding to localhost is what actually keeps the port private.
- **`restart: unless-stopped`** brings the containers back after a crash or reboot, unless you stopped them yourself.

> **Pin image versions in production.** `latest` is convenient for a first test. For a server you depend on, replace it with a specific version tag from the project's release page, so an update only happens when you change the tag.

### Validate and start

Check the file and the variable substitution before you start anything:

```bash
docker compose config --quiet && echo OK
```

No output except `OK` means the file is valid. Without `--quiet`, the command prints the final configuration with all variables filled in, which is useful for debugging (and shows your passwords, so do not paste it anywhere).

Start the stack and wait until the health checks pass:

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

`--wait` returns once all services are running and healthy, or fails with an error if one of them does not get there. Both services should show `(healthy)` in the `STATUS` column. Test the app from the server itself:

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

If something goes wrong, read the logs of one service:

```bash
docker compose logs --tail 50 app
```

### Put a reverse proxy in front

The app now listens on `127.0.0.1:8080`. To reach it under a domain with HTTPS, run a reverse proxy on the same server. With [Caddy](https://caddyserver.com) installed on the host, the whole configuration in `/etc/caddy/Caddyfile` is:

```text
rss.example.com {
    reverse_proxy 127.0.0.1:8080
}
```

Caddy gets a Let's Encrypt certificate automatically once DNS points to the server and ports 80 and 443 are open. Our [n8n guide](https://datapasa.com/wiki/guides/install-n8n-on-vps) shows the same pattern with DNS and firewall steps. If you would rather manage apps and domains in a web interface, look at [Coolify](https://datapasa.com/wiki/guides/install-coolify-on-vps).

After the first login you can remove `CREATE_ADMIN`, `ADMIN_USERNAME` and `ADMIN_PASSWORD` from `compose.yaml` and run `docker compose up -d` again.

## Start on boot

Nothing extra is needed. The Docker service is enabled at boot when installed from the packages, and Docker itself restarts containers according to their restart policy. Check the service once:

```bash
systemctl is-enabled docker
```

The restart policies you can choose from:

| Policy | After a crash | After a reboot | After `docker compose stop` |
|---|---|---|---|
| `no` (default) | Stays stopped | Stays stopped | Stays stopped |
| `on-failure` | Restarts if the exit code is not 0 | Stays stopped | Stays stopped |
| `always` | Restarts | Starts | Starts again after a reboot |
| `unless-stopped` | Restarts | Starts | Stays stopped |

`unless-stopped` fits most services. A systemd unit per project is not necessary. To be sure, reboot once with `sudo reboot`, reconnect and run `docker compose ps` in the project folder.

## Update images

Updating means downloading newer images and recreating the containers from them. The volume with your data stays:

```bash
cd /opt/miniflux
docker compose pull
docker compose up -d --wait
docker image prune -f
```

`up -d` recreates only the containers whose image or configuration changed. `docker image prune -f` deletes the old image layers that nothing uses anymore. With pinned tags, edit the tag in `compose.yaml` first, then run the same commands.

> **Major PostgreSQL versions.** Never change `postgres:18` to the next major version just by editing the tag. A new major version cannot read the old data directory, and the container will refuse to start. Take a dump with `pg_dump`, start the new version with an empty volume and restore the dump.

Before every update, have a fresh backup. A database dump from the running container is one command:

```bash
docker compose exec -T db pg_dump -U miniflux miniflux > miniflux-$(date +%F).sql
```

Store dumps off the server. See [how to back up your VPS](https://datapasa.com/wiki/server-management/back-up-your-vps) for options.

## Common problems

**The app keeps restarting with connection errors.** Check `docker compose ps`. If `db` is not `healthy`, look at `docker compose logs db`. A frequent cause is changing `POSTGRES_PASSWORD` after the first start: the PostgreSQL image only reads it when the data directory is empty, so the old password stays in the database.

**`variable is not set` warnings.** Compose cannot find a value. The `.env` file must be in the project folder (or passed with `--env-file`) and the names must match exactly.

**Port already allocated.** Another container or service already uses that host port. Find it with `sudo ss -tlnp | grep 8080` and choose another host port, for example `127.0.0.1:8081:8080`.

**Changes in compose.yaml have no effect.** `docker compose restart` restarts containers with their old configuration. Run `docker compose up -d` to apply changes.

For the full set of commands and flags, keep the [Docker Compose commands cheat sheet](https://datapasa.com/wiki/server-management/docker-compose-commands) at hand.
