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.