Docker Compose удобен на одном сервере: всё приложение — веб-сервис, база и прокси — описано в одном файле и поднимается одной командой. Но файл, который работал на ноутбуке, в продакшене приводит к типичным проблемам: логи заполняют диск, после сбоя контейнер не поднимается, база открыта в интернет в обход файрвола, а обновление кладёт сайт. Ниже — как настроить Docker Compose на сервере так, чтобы этого не случилось, на примере стека «приложение + PostgreSQL + обратный прокси Caddy».

Проверено на Ubuntu 24.04 и 26.04: Docker Engine 29.9.0, Docker Compose 5.6.0, PostgreSQL 18, Caddy 2.11, Miniflux 2.3.3.

Что нужно заранее

  • Сервер с Ubuntu 24.04 или 26.04 и пользователь admin с правами sudo — см. первую настройку сервера.
  • Домен, A-запись которого указывает на сервер, — для сертификата HTTPS. В примерах — example.com и адрес 203.0.113.10.
  • Открытые в файрволе порты 80 и 443.

Установка Docker Engine и Compose на Ubuntu 24.04 и 26.04

Ставим Docker из официального репозитория Docker — так вы получаете актуальные версии и обновления через apt. Пакет docker.io из репозитория Ubuntu и версия из snap отстают и устроены иначе. Ubuntu 26.04 в списке поддерживаемых систем есть (документация Docker), репозиторий для неё работает.

Сначала удалите пакеты, которые конфликтуют с официальными (если их нет, apt так и скажет):

sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc | cut -f1)

Подключите репозиторий: ключ подписи в /etc/apt/keyrings, источник — в формате .sources:

sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Строка Suites сама подставит кодовое имя системы: noble для 24.04, resolute для 26.04. Проверка:

sudo docker version --format '{{.Server.Version}}'   # 29.9.0
docker compose version                              # Docker Compose version v5.6.0
sudo docker run --rm hello-world                    # Hello from Docker!
systemctl is-enabled docker                         # enabled

Чтобы не писать sudo перед каждой командой, добавьте admin в группу docker и перезайдите на сервер:

sudo usermod -aG docker admin

Учтите: группа docker по сути равна правам root — через Docker можно смонтировать в контейнер любой каталог системы. Добавляйте в неё только тех, кому доверили бы sudo.

Ограничить размер логов

По умолчанию Docker пишет вывод контейнеров в JSON-файлы без ограничения размера, и болтливый контейнер за несколько месяцев может занять весь диск. Задайте ротацию для всех контейнеров в /etc/docker/daemon.json:

sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}
EOF
sudo systemctl restart docker

Теперь на каждый контейнер — не больше трёх файлов по 10 МБ. Настройка действует только на новые контейнеры: уже созданные пересоздайте командой docker compose up -d --force-recreate. Проверить, что получил конкретный контейнер, можно так:

docker inspect -f '{{.HostConfig.LogConfig}}' <контейнер>
# {json-file map[max-file:3 max-size:10m]}

Перезапуск Docker перезапускает и все работающие контейнеры — делайте это до запуска приложения или в спокойное время.

Проект: каталог, .env и секреты

Всё, что относится к приложению, держите в одном каталоге:

sudo install -d -o admin -g admin /opt/miniflux
cd /opt/miniflux

В качестве приложения возьмём Miniflux — читалку RSS: маленький образ, хранит данные в PostgreSQL и умеет сам проверять своё состояние. Ваше приложение встанет на его место так же.

Пароли не пишут в compose.yaml — этот файл обычно лежит в git. Их кладут в файл .env рядом, который Compose читает автоматически:

umask 077
cat > .env <<EOF
DOMAIN=example.com
POSTGRES_PASSWORD=$(openssl rand -hex 24)
ADMIN_PASSWORD=$(openssl rand -hex 12)
EOF
ls -l .env      # -rw------- 1 admin admin

umask 077 создаёт файл с правами 600 — читать его может только владелец. Пароли генерируются случайно; шестнадцатеричные, чтобы в них не было символов, которые ломают адрес подключения к базе. Добавьте .env в .gitignore.

Переменные окружения контейнера видны всем, у кого есть доступ к Docker, — например, в docker inspect. Если образ умеет читать пароль из файла (у PostgreSQL это POSTGRES_PASSWORD_FILE), можно передать его через secrets — файл появится в контейнере в /run/secrets/:

services:
  db:
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
secrets:
  db_password:
    file: ./db_password.txt

Для одного сервера, к которому есть доступ только у вас, .env с правами 600 — разумный минимум.

Файл compose.yaml для продакшена

Файл /opt/miniflux/compose.yaml:

x-logging: &logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

services:
  caddy:
    image: caddy:2.11
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      app:
        condition: service_healthy
    logging: *logging

  app:
    image: miniflux/miniflux:2.3.3
    restart: unless-stopped
    environment:
      DATABASE_URL: postgres://miniflux:${POSTGRES_PASSWORD:?не задан в .env}@db/miniflux?sslmode=disable
      RUN_MIGRATIONS: "1"
      CREATE_ADMIN: "1"
      ADMIN_USERNAME: admin
      ADMIN_PASSWORD: ${ADMIN_PASSWORD:?не задан в .env}
      BASE_URL: https://${DOMAIN:?не задан в .env}
    healthcheck:
      test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
      start_interval: 2s
    depends_on:
      db:
        condition: service_healthy
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 256M
    logging: *logging

  db:
    image: postgres:18
    restart: unless-stopped
    environment:
      POSTGRES_USER: miniflux
      POSTGRES_DB: miniflux
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?не задан в .env}
    volumes:
      - db_data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U miniflux -d miniflux"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
      start_interval: 2s
    shm_size: 128mb
    deploy:
      resources:
        limits:
          memory: 512M
    logging: *logging

volumes:
  db_data:
  caddy_data:
  caddy_config:

И /opt/miniflux/Caddyfile:

example.com {
	reverse_proxy app:8080 {
		lb_try_duration 30s
	}
}

Caddy сам получит сертификат Let's Encrypt для домена и будет перенаправлять HTTP на HTTPS. Дальше — что в этих файлах важно для продакшена.

Версии образов закреплены. Тег latest означает «что угодно на момент скачивания»: после очередного pull может приехать новая основная версия с несовместимыми изменениями. Для базы достаточно основной версии (postgres:18 получает только исправления), для приложения — точной.

${ПЕРЕМЕННАЯ:?текст} — обязательная переменная. Если её нет в .env, Compose откажется запускаться и напишет required variable ADMIN_PASSWORD is missing a value: не задан в .env, а не запустит базу с пустым паролем. Посмотреть итоговую конфигурацию с подставленными значениями: docker compose config.

Данные — в именованных томах. Том db_data переживает удаление и пересоздание контейнеров. Обратите внимание на путь: начиная с PostgreSQL 18 образ хранит данные в /var/lib/postgresql/18/docker, и том монтируют в /var/lib/postgresql, а не в …/data, как раньше (подробности).

x-logging и logging: *logging — ротация логов прямо в файле проекта, повтор настройки из daemon.json. С ней проект ведёт себя одинаково на любом сервере.

Перезапуск после сбоя: restart

restart: unless-stopped — Docker перезапускает контейнер, если процесс в нём завершился, и поднимает его после перезагрузки сервера. Варианты:

Значение Когда перезапускает
no (по умолчанию) Никогда
on-failure Только при выходе с ненулевым кодом
always Всегда, в том числе после ручной остановки — при перезапуске Docker
unless-stopped Всегда, кроме случая, когда вы остановили контейнер сами

Мы проверили: после kill -9 процесса приложения Docker сразу поднял контейнер заново (RestartCount стал 1). А docker kill и docker stop Docker считает ручной остановкой — контейнер остаётся остановленным, пока вы не выполните docker compose start или up -d.

Healthcheck и depends_on: condition: service_healthy

depends_on без условий гарантирует только порядок запуска контейнеров: приложение стартует, когда база ещё не готова принимать подключения. Чтобы ждать готовности, у сервиса должна быть проверка — healthcheck, — а у зависимого сервиса condition: service_healthy.

  • Для PostgreSQL — pg_isready. Ключ -h 127.0.0.1 проверяет подключение по сети: пока образ инициализирует новую базу, сервер слушает только локальный сокет, и проверка без -h может сообщить о готовности раньше времени.
  • Для приложения — его собственная проверка. В Miniflux это miniflux -healthcheck auto; у своего приложения сделайте адрес вроде /health и проверяйте его через curl или wget, если они есть в образе.
  • start_period — время на запуск, когда неудачные проверки не считаются. start_interval — как часто проверять в этот период; с ним контейнер становится healthy за пару секунд, а не через полный interval. Работает начиная с Docker Engine 25.

При запуске Compose дожидается проверок по цепочке:

 Container miniflux-db-1 Healthy
 Container miniflux-app-1 Started
 Container miniflux-app-1 Healthy
 Container miniflux-caddy-1 Started

Важное ограничение: depends_on — это логика Compose. После перезагрузки сервера контейнеры поднимает сам Docker, одновременно и без учёта зависимостей. Приложение должно уметь переподключаться к базе или падать — тогда его перезапустит restart.

Порты: наружу только прокси, остальное — на 127.0.0.1

В нашем файле порты публикует только Caddy. У app и db секции ports нет: они доступны другим контейнерам проекта по именам app и db, но не снаружи.

Это важно, потому что Docker открывает опубликованные порты в обход UFW. Мы проверили: при включённом UFW, где разрешён только SSH, порт 5433:5432 был доступен с другой машины. О настройке UFW — в статье о первой настройке сервера. Почему порты контейнеров открыты при включённом UFW и как их закрыть — в статье «Docker обходит UFW».

Если к сервису нужен доступ с самого сервера — например, подключиться к базе через psql или поставить перед приложением nginx, установленный в систему, — публикуйте порт только на локальный адрес:

    ports:
      - "127.0.0.1:5432:5432"

С такой записью порт слушается только на 127.0.0.1 и снаружи закрыт. С компьютера к нему можно подключиться через SSH-туннель: ssh -L 5432:127.0.0.1:5432 admin@203.0.113.10.

Ограничения памяти и процессора

Без ограничений утечка памяти в одном контейнере приводит к тому, что ядро начинает завершать процессы по всему серверу, и первой может пострадать база. Секция deploy.resources.limits работает в обычном Compose, без Swarm:

    deploy:
      resources:
        limits:
          cpus: "1.0"     # не больше одного ядра
          memory: 256M    # при превышении процесс в контейнере будет завершён

Короткая старая запись mem_limit: 256m и cpus: 1.0 на уровне сервиса делает то же самое. Проверка:

docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}"
NAME               MEM USAGE / LIMIT     CPU %
miniflux-caddy-1   12.78MiB / 3.825GiB   0.00%
miniflux-app-1     15.09MiB / 256MiB     0.00%
miniflux-db-1      32.58MiB / 512MiB     0.09%

Подбирайте лимиты по docker stats под реальной нагрузкой с запасом в полтора-два раза. Сумма лимитов не должна превышать память сервера.

Запуск и проверка

cd /opt/miniflux
docker compose up -d
docker compose ps

У db и app в колонке STATUS должно быть (healthy). Откройте https://example.com — появится страница входа; пользователь admin, пароль — grep ADMIN_PASSWORD .env.

Логи:

docker compose logs -f --tail 100 app     # следить за приложением, выход — Ctrl+C
docker compose logs --since 1h db         # база за последний час

Резервные копии PostgreSQL

Не копируйте файлы тома работающей базы — копия может оказаться несогласованной. Правильный способ — pg_dump внутри контейнера базы:

docker compose exec -T db pg_dump -U miniflux -d miniflux -Fc > miniflux.dump

-T отключает терминал — без этого бинарный вывод испортится. -Fc — сжатый формат, из которого pg_restore умеет восстанавливать базу целиком или по частям. Скрипт для ежедневных копий /opt/miniflux/backup.sh хранит их 14 дней:

#!/bin/sh
set -eu
umask 077
cd /opt/miniflux
mkdir -p backups
f="backups/miniflux-$(date +%F).dump"
docker compose exec -T db pg_dump -U miniflux -d miniflux -Fc > "$f.tmp"
mv "$f.tmp" "$f"
find backups -name '*.dump' -mtime +14 -delete

Временный файл .tmp нужен, чтобы оборванная выгрузка не выглядела готовой копией. Сделайте скрипт исполняемым, проверьте и добавьте в расписание crontab -e (каждый день в 03:30):

chmod +x /opt/miniflux/backup.sh
/opt/miniflux/backup.sh && ls -l /opt/miniflux/backups
30 3 * * * /opt/miniflux/backup.sh

Восстановление — остановите приложение, загрузите копию, запустите снова:

docker compose stop app
docker compose exec -T db pg_restore -U miniflux -d miniflux --clean --if-exists < backups/miniflux-2026-10-11.dump
docker compose start app

Копия на том же сервере не спасёт при потере сервера. Регулярно забирайте backups/ на другую машину — например, rsync по SSH со своего компьютера или второго сервера. И хотя бы раз проверьте восстановление на тестовой копии.

Обновление образов и минимальный простой

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

pull скачивает новые версии образов, пока старые контейнеры работают. up -d пересоздаёт только те контейнеры, у которых изменился образ или настройки, остальные остаются Running. image prune -f удаляет старые образы, на которые больше ничего не ссылается. Чтобы обновить приложение до новой версии, поменяйте тег в compose.yaml и выполните те же команды.

С одним экземпляром приложения простой при обновлении неизбежен — пока старый контейнер останавливается, а новый запускается. Его можно сделать незаметным для посетителей: настройка lb_try_duration 30s в Caddyfile заставляет Caddy не возвращать ошибку сразу, а повторять попытку до 30 секунд. Мы замеряли пересоздание контейнера приложения, отправляя запрос каждые 0,1 секунды: без этой настройки — три ответа 502, с ней — ни одной ошибки, самый долгий запрос ждал около 13 секунд.

Перед обновлением базы на новую основную версию (например, с 18 на 19) сделайте резервную копию: файлы данных между основными версиями несовместимы, простая смена тега не сработает.

Запуск Compose после перезагрузки

Отдельная служба systemd для Compose не нужна. Достаточно двух условий: служба Docker включена (systemctl is-enabled docker → enabled, при установке из репозитория это так) и у контейнеров задан restart: unless-stopped. Мы проверили перезагрузкой: через 25 секунд все три контейнера работали, db и app в состоянии healthy, сайт отвечал.

Контейнеры, которые вы остановили вручную через docker compose stop, после перезагрузки не поднимутся — это поведение unless-stopped, мы его тоже проверили.

Очистка диска

Образы, остановленные контейнеры и кеш сборки со временем занимают гигабайты. Сначала посмотрите, что сколько занимает:

docker system df

Безопасные команды очистки:

docker image prune -f        # образы без тега, оставшиеся после обновлений
docker image prune -a -f     # все образы, которые не использует ни один контейнер
docker builder prune -f      # кеш сборки, если собираете образы на сервере

Осторожно с томами. docker volume prune и docker system prune --volumes в современных версиях Docker удаляют только анонимные тома, но docker volume prune -a удалит и именованные тома, к которым не подключён ни один контейнер, — например, базу, если вы как раз остановили проект через docker compose down. На сервере с данными эту команду не запускайте. По той же причине не используйте docker compose down -v: ключ -v удаляет тома проекта.

Если не работает

Что видно Что делать
dependency failed to start: container … is unhealthy Проверка здоровья не проходит. Причину покажет docker inspect --format '{{json .State.Health}}' <контейнер> и docker compose logs <сервис>
База не стартует: in 18+, these Docker images are configured to store database data in a format… Том смонтирован в /var/lib/postgresql/data. Для PostgreSQL 18 монтируйте его в /var/lib/postgresql
required variable … is missing a value Нет переменной в .env, или команда запущена не из каталога проекта
502 от Caddy Приложение не запущено или не на том порту: docker compose ps, docker compose logs app
Caddy не получает сертификат A-запись домена не указывает на сервер или закрыты порты 80 и 443: docker compose logs caddy
Порт базы доступен из интернета В ports нет 127.0.0.1: — Docker открывает порты в обход UFW
Правки Caddyfile не применились Перечитайте конфигурацию: docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile. Если редактор заменил файл новым, а не изменил старый, контейнер видит прежнюю версию — выполните docker compose restart caddy
Закончилось место на диске docker system df, затем раздел «Очистка диска»; проверьте ротацию логов

Что дальше

  • Если сервер новый — начните с первой настройки Ubuntu и входа по SSH-ключу.
  • Если базу удобнее держать в системе, а не в контейнере, — PostgreSQL на Ubuntu 24.04 и 26.04.
  • Небольшому сервису без базы контейнеры не обязательны: как запустить программу как службу systemd с автоперезапуском и логами, показано в статье о Telegram-боте на сервере.
  • Что происходит, когда контейнер упирается в лимит памяти, и как это увидеть в журналах — OOM killer и нехватка памяти.
  • Полная установка Docker с ротацией логов, закреплением версии и rootless-режимом — Как установить Docker на Ubuntu и Debian.
  • Обратный прокси перед контейнерами — Nginx с Let's Encrypt или Caddy; копии базы вне сервера — бэкап PostgreSQL; мониторинг — Uptime Kuma.
  • Если место на диске всё-таки кончилось — как найти и освободить; журналы служб и их размер — journald и logrotate.