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.
- Beginner
- 35 min read
- Updated
Tested on: Ubuntu 24.04 LTS, Ubuntu 26.04 LTS, Debian 12, Debian 13
This guide is not available in your language yet, so it is shown in English.
On this page
- Prerequisites
- Step 1 — Create a project directory
- Step 2 — Put settings and secrets in .env
- Step 3 — Write compose.yaml
- Step 4 — Start services in the right order with healthchecks
- Step 5 — Choose restart policies and keep ports private
- Step 6 — Check, start and watch the stack
- Step 7 — Use profiles for optional services
- Step 8 — Everyday commands and what down -v deletes
- Step 9 — Run several projects on one server
- Update a Compose stack
- Back up and restore
- Troubleshooting
- The "POSTGRES_USER" variable is not set. Defaulting to a blank string.
- dependency failed to start: container demo-db-1 is unhealthy
- Bind for 127.0.0.1:8080 failed: port is already allocated
- My changes to compose.yaml or .env do nothing
- the attribute version is obsolete, it will be ignored
- A container port is reachable from the internet although ufw blocks it
- Next steps
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
sudorights and SSH key login. See Secure a new Linux server and Set up SSH keys. - Docker Engine with the Compose plugin from Docker's repository: Install Docker on Ubuntu or Install Docker on 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:
sudo mkdir -p /opt/demo
sudo chown $USER:$USER /opt/demo
cd /opt/demoRun 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:
openssl rand -hex 32# /opt/demo/.env
POSTGRES_USER=demo
POSTGRES_PASSWORD=paste-the-generated-value-here
POSTGRES_DB=demo
WEB_PORT=8080Then make the file readable only by you:
chmod 600 /opt/demo/.envThe 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).
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:
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:
mkdir -p /opt/demo/html
echo "Hello from Compose" > /opt/demo/html/index.htmlWhat each part does:
services— one entry per container.imagenames the image to pull; Compose pulls it the first time you start the stack.volumes(top level) — declares the named volumedb_data, which Docker stores under/var/lib/docker/volumes/. It survives container recreation. The./htmlentry underwebis 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 hostdb, port5432. Only use the published host port from outside the network. postgres:18and 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
healthcheckondbrunspg_isreadyinside the container every 10 seconds.start_periodgives PostgreSQL 30 seconds to initialise before failures count, and after 5 failed checks the container is markedunhealthy. $${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) andservice_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, Nginx with Certbot or Traefik. Database ports such as 5432 need no ports: entry at all, because other services reach them over the project network.
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:
docker compose configStart the stack in the background, then list its containers:
docker compose up -d
docker compose psup -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:
curl -I http://127.0.0.1:8080You 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:
docker compose logs -f
docker compose logs -f --tail 100 dbStep 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:
docker compose --profile tools up -dAdminer listens on 127.0.0.1:8081, so open it through an SSH tunnel from your own computer:
ssh -L 8081:127.0.0.1:8081 your-user@203.0.113.10Then 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:
docker compose --profile tools stop adminer
docker compose --profile tools rm -f adminerYou 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.
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.1port. With Traefik or 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:
networks:
proxy:
name: proxy
external: trueThe 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:
cd /opt/demo
docker compose pull
docker compose up -d
docker compose ps
docker image prunepull 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,.envand 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:
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 demoTo 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:
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 startTo 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:
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 -dCopy 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.
- Route containers by labels with Traefik.
- Manage stacks from a browser with Portainer.
- Compare servers for container workloads on the Docker hosting page.
- Read the full Compose file reference 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.
Sources
- docs.docker.com/compose/intro/compose-application-model
- docs.docker.com/compose/intro/history
- docs.docker.com/reference/compose-file/services
- docs.docker.com/reference/compose-file/volumes
- docs.docker.com/reference/compose-file/version-and-name
- docs.docker.com/compose/how-tos/environment-variables/variable-inte…
- docs.docker.com/compose/how-tos/environment-variables/set-environme…
- docs.docker.com/compose/how-tos/startup-order
- docs.docker.com/compose/how-tos/profiles
- docs.docker.com/compose/how-tos/project-name
- docs.docker.com/compose/how-tos/networking
- docs.docker.com/compose/how-tos/production