Skip to content

TutorialsContainers & Docker

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
  1. Prerequisites
  2. Step 1 — Create a project directory
  3. Step 2 — Put settings and secrets in .env
  4. Step 3 — Write compose.yaml
  5. Step 4 — Start services in the right order with healthchecks
  6. Step 5 — Choose restart policies and keep ports private
  7. Step 6 — Check, start and watch the stack
  8. Step 7 — Use profiles for optional services
  9. Step 8 — Everyday commands and what down -v deletes
  10. Step 9 — Run several projects on one server
  11. Update a Compose stack
  12. Back up and restore
  13. Troubleshooting
  14. The "POSTGRES_USER" variable is not set. Defaulting to a blank string.
  15. dependency failed to start: container demo-db-1 is unhealthy
  16. Bind for 127.0.0.1:8080 failed: port is already allocated
  17. My changes to compose.yaml or .env do nothing
  18. the attribute version is obsolete, it will be ignored
  19. A container port is reachable from the internet although ufw blocks it
  20. 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

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:

ResourceMinimum (official)Suggested starting point
CPUNot published1 vCPU
RAMNot published1 GB
DiskNot published10 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:

SyntaxResult
${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:

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:

PolicyBehaviour
noDefault. Never restarted automatically
alwaysAlways restarted, also after a reboot
on-failure[:N]Restarted only after a non-zero exit code, optionally at most N times
unless-stoppedLike 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:

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

CommandWhat it does
docker compose up -dCreates or updates the stack; recreates only containers whose config or image changed
docker compose stop / startStops or starts containers without removing them
docker compose restart webRestarts a container; does not apply changes to compose.yaml
docker compose exec db psql -U demoRuns a command in a running container
docker compose downRemoves containers and the project network; keeps volumes
docker compose down -vAlso deletes named and anonymous volumes
docker compose lsLists 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.1 port. 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:

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:

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

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

Сгенерировать пароль

Please confirm