Appearance
Install Docker Compose on Ubuntu 22.04, 24.04, 26.04
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 first. It installs Compose along the way.
- SSH access with sudo rights, ideally as a user in the
dockergroup so you can skipsudobefore 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.
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 |
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 |
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. Compose is one package away:
bash
sudo apt update
sudo apt install docker-compose-plugin
docker compose versionIt 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 versionThis 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. 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 versionuname -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-composeIf 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 -dWhat 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-composeExample project: app and PostgreSQL
The example is Miniflux, 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
.envfile, 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.1only, 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/minifluxThe 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 .envWrite 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.yamlyaml
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/postgresqlis the right mount point for thepostgres:18image 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.healthcheckondbrunspg_isreadyevery 10 seconds. The container becomeshealthyonly when PostgreSQL accepts connections.depends_onwithcondition: service_healthymakes Compose wait for that state before it startsapp.restart: truerestarts the app when Compose restarts the database, for example after you change its settings.@dbinDATABASE_URLis the service name. Compose puts both containers on a private network where each service is reachable by its name. The database has noports:section, so it is not reachable from outside at all.127.0.0.1:8080:8080publishes 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-stoppedbrings 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 OKNo 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:8080If something goes wrong, read the logs of one service:
bash
docker compose logs --tail 50 appPut 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 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 shows the same pattern with DNS and firewall steps. If you would rather manage apps and domains in a web interface, look at Coolify.
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 dockerThe 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 -fup -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).sqlStore dumps off the server. See how to 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 at hand.