Несколько сервисов на одном VPS: Docker Compose вместо десяти docker run

Три docker run с разными флагами - это три места, где конфигурация может разъехаться при первом же ребуте. Один docker-compose.yml с reverse proxy и парой сервисов за ним решает это раз и навсегда: сервисы видят друг друга по имени, наружу торчит только один порт, у каждого контейнера свой потолок по памяти и CPU, а обновление - это pull и up -d, а не ритуал. Собираем стек из Caddy, Vaultwarden и Uptime Kuma и разбираем, где у него границы.

Один сервис в Docker на VPS - это просто. Несколько сервисов на одном VPS - уже вопрос архитектуры, а не просто повторить команду ещё раз, если каждый раз поднимать очередной сервис отдельным docker run с длинной строкой флагов, которую полгода спустя никто не сможет повторить один в один. Docker Compose решает эту задачу за счёт одного файла, который описывает весь стек целиком: образы, порты, тома, переменные окружения и то, как сервисы находят друг друга. Ниже - реальный стек из reverse proxy Caddy и двух сервисов за ним, сеть с разрешением имён, тома для бэкапов, лимиты памяти и CPU на контейнер, обновление без ритуалов и честный разговор о том, где у этого подхода потолок.

Коротко. Один docker-compose.yml поднимает весь стек одной командой и сам создаёт для него общую сеть, в которой контейнеры видят друг друга по имени сервиса - без ручной настройки DNS. Наружу должен смотреть только reverse proxy: остальные сервисы получают доступ друг к другу по внутренней сети, а порт на хост публикуется, только когда к сервису действительно нужен доступ снаружи Docker-сети. Данные, которые должны пережить пересоздание контейнера, выносятся томом - именованным volume или обычным каталогом на диске, и бэкапить нужно именно его. У каждого сервиса стоит свой потолок памяти и CPU через deploy.resources.limits, чтобы один зависший или раздутый контейнер не утащил с собой остальные. Обновление - это docker compose pull и docker compose up -d, откат - это смена тега образа на предыдущий и снова up -d, при условии что тег вообще был закреплён, а не оставлен на latest.

Зачем Compose для нескольких сервисов на одном VPS, если можно docker run или отдельный сервер под каждый

Первый инстинкт после того, как заработал один контейнер - запустить второй тем же способом: скопировать команду, поменять образ и порт, дописать ещё один docker run -d --name ... -p ... -v ... --restart=always .... Работает. Пока сервисов два. На третьем начинается разъезд: где-то забыт --restart, где-то том смонтирован по другому пути, потому что копировали не из того окна терминала. Через месяц никто, включая вас, не сможет с уверенностью сказать, какими именно флагами поднят конкретный контейнер - придётся разбирать через docker inspect.

Compose устраняет это простым способом: конфигурация лежит в файле, а не в истории команд. docker-compose.yml - это одновременно и документация стека, и единственный источник правды о том, что должно быть запущено. Поднять всё - docker compose up -d. Остановить всё - docker compose down. Обновить один сервис, не трогая остальные, - строка в этом же файле. Файл можно положить в git, сравнить версии, откатить правку так же, как код.

Мы бы выбрали здесь Compose не потому, что docker run - это плохо само по себе. Он прекрасно работает для одного контейнера. Проблема в другом: он ничего не оставляет после себя, кроме строки в истории терминала, которую вы почти наверняка не найдёте, когда она понадобится.

Второй вариант, который приходит в голову - не мешать сервисы на одном сервере вовсе, а взять по VPS под каждый. Иногда это правильный выбор: если сервисам нужна разная изоляция или у них принципиально разная нагрузка. Но для типичного домашнего или командного набора self-hosted инструментов - менеджер паролей, панель мониторинга, пара автоматизаций - это оверинжиниринг. Три тарифа вместо одного, три набора обновлений системы, три места, где может протухнуть SSH-ключ. Один VPS с Compose даёт тот же результат за один тариф, пока сервисы не начинают друг другу реально мешать по ресурсам - об этом ближе к концу статьи.

Что важно

Несколько docker run

Один docker-compose.yml

Где хранится конфигурация

В истории shell или в отдельных скриптах, легко разъехаться

В одном файле, версионируется в git

Сеть между сервисами

Настраивается вручную, по IP или через отдельный docker network create

Создаётся автоматически, разрешение имён из коробки

Поднять/остановить всё разом

Нет - только по контейнеру за раз

docker compose up -d / down

Обновить один сервис

Останавливать и пересоздавать контейнер руками

Правка тега в файле, docker compose up -d

Что собираем: Caddy и два сервиса за ним

Для примера возьмём три реальных сервиса, которые логично держать вместе: Caddy как reverse proxy с автоматическим HTTPS, Vaultwarden - лёгкая self-hosted замена серверной части Bitwarden для хранения паролей, и Uptime Kuma - панель мониторинга, которая следит, отвечают ли остальные сервисы. Комбинация не случайная: у Uptime Kuma появляется повод обращаться к Vaultwarden по внутренней сети, а не только снаружи - хорошая иллюстрация того, зачем вообще нужна сеть между контейнерами, а не просто общий хост.

Каталог для стека:

sudo mkdir -p /opt/stack/caddy /opt/stack/vaultwarden /opt/stack/kuma

cd /opt/stack

Файл /opt/stack/Caddyfile:

{

email [email protected]

}

vault.example.com {

reverse_proxy vaultwarden:80

}

status.example.com {

reverse_proxy uptime-kuma:3001

}

  • email в глобальном блоке - адрес, который Caddy передаёт удостоверяющему центру при выпуске сертификата; не обязателен технически, но письмо об истечении домена или проблеме с ACME придёт именно туда.
  • reverse_proxy vaultwarden:80 - адрес указан по имени сервиса из compose-файла, а не по IP и не по localhost. Это работает только потому, что Caddy и Vaultwarden сидят в одной сети, которую Compose создаст сам - подробнее в следующем разделе.
  • Каждый домен - отдельный блок. Так на одном Caddy и одном IP-адресе спокойно живут произвольное число сайтов, у каждого свой сертификат.

А вот и сам /opt/stack/docker-compose.yml:

services:

caddy:

image: caddy:2.11.4

container_name: caddy

restart: unless-stopped

ports:

- "80:80"

- "443:443"

- "443:443/udp"

volumes:

- ./Caddyfile:/etc/caddy/Caddyfile

- caddy_data:/data

- caddy_config:/config

deploy:

resources:

limits:

cpus: "1.0"

memory: 256M

vaultwarden:

image: vaultwarden/server:1.37.3

container_name: vaultwarden

restart: unless-stopped

environment:

- DOMAIN=https://vault.example.com

- SIGNUPS_ALLOWED=false

volumes:

- ./vaultwarden:/data

deploy:

resources:

limits:

cpus: "0.5"

memory: 256M

uptime-kuma:

image: louislam/uptime-kuma:2.5.5

container_name: uptime-kuma

restart: unless-stopped

volumes:

- ./kuma:/app/data

deploy:

resources:

limits:

cpus: "0.5"

memory: 512M

volumes:

caddy_data:

caddy_config:

Ни у Vaultwarden, ни у Uptime Kuma здесь нет секции ports вообще. Это не упрощение для примера - это и есть правильная настройка. К обоим сервисам обращается только Caddy, а он делает это по внутренней сети на внутренний порт контейнера, и с хоста наружу этот порт торчать не обязан.

Поднимаем:

cd /opt/stack

sudo docker compose up -d

sudo docker compose ps

В выводе ps все три сервиса должны быть в статусе running. У caddy в колонке портов - 0.0.0.0:80->80/tcp и то же для 443, у vaultwarden и uptime-kuma в этой колонке будет пусто - и это правильно, порт не публиковался.

Сеть: как контейнеры находят друг друга по имени

Когда вы поднимаете стек, Compose создаёт для проекта отдельную виртуальную сеть - это называется bridge-сеть, программный сетевой мост между контейнерами, которого физически нигде нет, но с точки зрения контейнера он ведёт себя как обычная локальная сеть. Все сервисы из файла подключаются к ней автоматически, без единой строчки настройки с вашей стороны.

Внутри этой сети работает встроенный DNS-сервер Docker. Он резолвит имя сервиса - то, что написано слева от двоеточия в docker-compose.yml - в текущий внутренний IP-адрес соответствующего контейнера. Отсюда и берётся строка reverse_proxy vaultwarden:80 в Caddyfile: vaultwarden - не магическое слово, а буквально имя сервиса, за которое Docker подставит правильный адрес, даже если контейнер потом пересоздастся с новым IP.

Отсюда же вытекает главное практическое правило: публикуйте порт директивой ports только тому сервису, к которому реально нужен доступ снаружи Docker-сети - с хоста или из интернета. Всё, что нужно только соседним контейнерам этого же стека, получает доступ через внутреннюю сеть на внутренний порт, и наружу его выставлять незачем. Меньше открытых портов - меньше поверхности атаки. Это не абстрактная гигиена. У Docker есть отдельная особенность, из-за которой лишний опубликованный порт не спасёт даже правило ufw: при публикации порта Docker сам пишет правила в iptables, которые обрабатываются раньше правил ufw. Подробно этот момент, включая как проверить его на своём сервере, разобран в статье про AnythingLLM на VPS.

Что если два разных compose-проекта на одном VPS должны видеть друг друга - скажем, общая база данных для нескольких стеков? По умолчанию каждый проект живёт в собственной изолированной сети и соседей не видит вообще, и это осознанное поведение, а не недосмотр. Свести их вместе можно через docker network create и общую внешнюю сеть, но пока у вас один стек в одном каталоге, эта развилка вам не нужна.

Тома: где на самом деле лежат ваши данные

Файловая система контейнера живёт ровно столько, сколько живёт сам контейнер. docker compose down, обновление образа, случайный docker compose rm - и всё, что не вынесено отдельно, пропадает вместе с контейнером. Persistent-данные - это то, что явно указано в volumes.

Секунду - тут стоит притормозить, потому что путаница между двумя способами вынести данные наружу ломает бэкапы чаще, чем что-либо ещё в этой статье.

В примере выше два разных способа сделать это, и разница между ними стоит того, чтобы её понимать. ./Caddyfile:/etc/caddy/Caddyfile и ./vaultwarden:/data - это bind mount: обычный каталог на диске сервера, путь к которому вы видите и знаете заранее, в примере это /opt/stack/vaultwarden. caddy_data:/data - это именованный volume: Docker сам создаёт и хранит его, по умолчанию где-то в /var/lib/docker/volumes/, и вы обращаетесь к нему по имени, а не по пути.

Для сервиса, за которым вы гоняетесь с бэкапами и хотите точно знать, где лежат файлы, bind mount удобнее - путь предсказуем, его можно смотреть, копировать и архивировать напрямую, без docker volume inspect. Именно поэтому в примере данные Vaultwarden и Uptime Kuma - это обычные каталоги /opt/stack/vaultwarden и /opt/stack/kuma, а не именованные тома: их бэкап - буквально tar этих двух каталогов или включение их в общий план резервного копирования сервера, который подробно описан в статье про бэкап VPS на HIP. Каталоги Caddy оставлены именованными томами специально - там лежат кэш сертификатов и внутренний конфиг самого Caddy, которые не критично потерять: Caddy перевыпустит сертификат заново при следующем запуске, если данные пропадут.

Лимиты: чтобы один сервис не забрал память у остальных

Без ограничений контейнер может занять сколько угодно памяти и CPU - вплоть до всего, что есть на сервере. На VPS с несколькими сервисами это означает, что утечка памяти в одном контейнере или всплеск нагрузки в другом способны уронить весь стек разом, включая совершенно не связанные с проблемой сервисы.

Ограничивается это прямо в compose-файле. Без сторонних инструментов, без отдельного мониторинга, без cron-скрипта, который что-то там убивает по расписанию.

В текущей спецификации Compose это блок deploy.resources.limits на уровне сервиса - и, что важно, современный docker compose up (плагин, а не старый бинарник docker-compose через дефис) применяет этот блок сразу, без Swarm и без флага --compatibility, которые требовались в более старых версиях инструмента. Docker переводит значения cpus и memory в лимиты Linux cgroups - механизма ядра, который умеет считать и ограничивать ресурсы для группы процессов. Есть и более старый, плоский синтаксис - top-level поля mem_limit и cpus прямо на сервисе, без вложенности в deploy - он тоже работает, но deploy.resources.limits - актуальная форма записи по текущей спецификации.

Разберём, зачем такие цифры взяты для каждого сервиса в примере. Ниже - ориентиры по независимым источникам и обсуждениям сообщества, а не измерение на конкретном железе HIP; на своей нагрузке сверяйтесь через docker stats.

Сервис

Типичное потребление

Лимит в примере

Почему

Caddy

50-128 МБ в спокойном режиме, известны случаи скачков до 400-500 МБ при определённых конфигурациях под нагрузкой

256 МБ, 1 CPU

Запас на всплеск, но не безлимит - если что-то пошло не так, лучше падение с понятной причиной, чем захват всей памяти сервера

Vaultwarden

от 10-30 МБ на простое до около 100 МБ для небольшой семейной установки

256 МБ, 0.5 CPU

Сервис лёгкий сам по себе, лимит здесь скорее страховка на случай неожиданного поведения, чем реальный потолок

Uptime Kuma

около 80-120 МБ на простое, растёт с числом мониторов и историей проверок

512 МБ, 0.5 CPU

История срабатываний накапливается в базе внутри контейнера, и памяти со временем нужно чуть больше, чем в день установки

Ключевое следствие лимита по памяти - если контейнер всё же упирается в потолок, ядро отправляет OOM-килл (Out Of Memory kill, принудительное завершение процесса при нехватке памяти) именно этому контейнеру, а не случайному процессу на хосте и не системе целиком. Он перезапустится по политике restart: unless-stopped, а соседи по стеку этого даже не заметят. Без лимитов ровно тот же сценарий разворачивается иначе: память кончается у всего сервера сразу, и решение о том, кого убить, принимает уже глобальный OOM killer - по своим правилам, не обязательно тому контейнеру, который виноват.

Обновление и откат: pull, up -d, и путь назад

Обновить сервис - значит скачать новую версию образа и пересоздать под неё контейнер. В Compose это делается двумя короткими командами:

cd /opt/stack

sudo docker compose pull

sudo docker compose up -d

  • pull скачивает образы по тегам, которые прямо сейчас указаны в файле, - и ничего не меняет в запущенных контейнерах.
  • up -d сравнивает то, что скачано и описано в файле, с тем, что реально запущено, и пересоздаёт только те сервисы, у которых образ или конфигурация изменились. Остальные два сервиса стека остаются нетронутыми - это и есть разница между обновлением всего сразу и обновлением одного сервиса в изоляции.

Сразу после - посмотреть логи нового контейнера, а не считать обновление законченным по факту запуска процесса:

sudo docker compose logs -n 50 vaultwarden

Здесь и всплывает разница между latest и закреплённым тегом, ради которой стоит один раз перебороть лень. Если в файле стоит vaultwarden/server:latest, откатиться некуда: latest у большинства проектов - это плавающий указатель на самый свежий образ, а не версия, и повторный pull просто снова притащит последнее, что есть. Закреплённый тег вроде 1.37.3 в примере выше - это конкретная, неизменная версия, и откат при проблеме - это правка одной строки в файле на предыдущий тег и снова docker compose up -d:

sudo docker compose up -d vaultwarden

Один нюанс: если предыдущий образ уже был скачан раньше и не удалён локально, откат занимает секунды - Docker просто пересоздаёт контейнер из уже лежащего образа. Если образ был удалён (например, командой docker image prune), Compose скачает нужную версию заново по её тегу - дольше, но так же надёжно, при условии что тег вообще существует в реестре.

У Caddy отдельно есть способ применить изменения в конфиге - не в образе, а в самом Caddyfile - без пересоздания контейнера вообще:

sudo docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile

По документации Caddy это не обрывает уже открытые соединения к другим доменам, обслуживаемым тем же процессом: новая конфигурация поднимается рядом со старой, и только после успешной проверки старая гасится. Удобно, когда меняете правило для одного домена и не хотите даже на секунду трогать остальные сайты за этим же Caddy.

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

  • Bind for 0.0.0.0:80 failed: port is already allocated. Порт 80 уже занят другим процессом - часто заранее установленным nginx или apache. Проверьте sudo ss -ltnp | grep :80 и либо остановите тот процесс, либо смените порт у Caddy в compose-файле, если он должен сосуществовать с чем-то ещё.
  • Caddy не может выпустить сертификат. Первым делом проверьте, что A-запись домена реально указывает на IP этого сервера, а порты 80 и 443 доступны снаружи - без порта 80 ACME-проверка обычно не проходит. Логи смотрятся через docker compose logs -n 50 caddy.
  • Vaultwarden поднялся, но вход через приложение или WebAuthn не работает. Проверьте, что переменная DOMAIN задана и совпадает с реальным публичным адресом по https - без неё часть функциональности, завязанной на защищённый контекст браузера, попросту не включается.
  • Uptime Kuma не видит другие сервисы этого же стека. При добавлении монитора указывайте адрес по имени сервиса из compose-файла и внутреннему порту - например, vaultwarden:80, а не localhost и не внешний домен. localhost внутри контейнера - это сам контейнер, а не хост и не соседи по сети.
  • Контейнер регулярно падает по OOM. Проверьте лимит через docker inspect <имя> --format '{{.HostConfig.Memory}}' и потребление через docker stats --no-stream: возможно, лимит просто занижен для реальной нагрузки, а не сервис ведёт себя неправильно.

Когда одного VPS с Compose уже мало

Честно говоря, для большинства персональных и небольших командных стеков этот потолок отодвинут очень далеко - на практике до него не доходит подавляющее большинство читателей этой статьи. Но он есть, и полезно знать его заранее, а не после инцидента.

Ситуация

Один VPS + Compose

Несколько VPS

Оркестрация (Swarm/Kubernetes)

Сервисам систематически не хватает суммарных ресурсов одной машины

Не подходит

Подходит - разносите по разным VPS

Подходит, если серверов уже несколько

Нужна изоляция между клиентскими проектами на уровне ядра, не только cgroups

Слабая изоляция

Полная изоляция отдельными VPS

Зависит от настройки, обычно тоже отдельные ноды

Нужно пережить полную потерю одного физического сервера без даунтайма

Не переживёт - весь стек на одной машине

Нужна отдельная логика failover, сама по себе не даётся

Родная функциональность - под это и создавалась

Достаточно пережить падение и перезапуск одного контейнера

Решается restart-политикой уже сейчас

Избыточно для этой задачи

Избыточно для этой задачи

Если ваш случай - нижняя строка таблицы, а не верхние три, менять ничего не нужно. Один VPS с Compose и разумными лимитами на сервис - это не временное решение, которое стыдно показать senior-разработчику, а рабочая архитектура для абсолютного большинства self-hosted стеков.

FAQ

Чем docker compose лучше нескольких docker run?

Один YAML-файл описывает весь стек - образы, порты, тома, переменные окружения и связи между сервисами, - а не набор команд, разбросанных по истории терминала. Compose поднимает и останавливает весь стек одной командой и сам создаёт общую сеть с разрешением имён между сервисами, без ручной настройки.

Как сервисы в docker compose находят друг друга по имени?

Compose создаёт для проекта отдельную bridge-сеть и подключает к ней все сервисы. Внутри работает встроенный DNS Docker: имя сервиса из файла резолвится в его текущий внутренний IP, и это продолжает работать даже после пересоздания контейнера с новым адресом.

Нужно ли публиковать порт каждого сервиса наружу?

Нет. Директива ports нужна только тем сервисам, к которым обращаются снаружи Docker-сети - с хоста или из интернета. Сервисы, которые видит только другой контейнер этого же стека, получают доступ по внутренней сети на внутренний порт без публикации.

Где docker compose хранит данные контейнеров и как это бэкапить?

Данные без явного тома живут только в файловой системе контейнера и пропадают при его пересоздании. Персистентные данные - это то, что вынесено в volumes: именованный volume (хранится Docker'ом в /var/lib/docker/volumes/) или bind mount - обычный каталог на диске сервера. Бэкапить нужно эти каталоги.

Как ограничить память и CPU для одного контейнера в compose?

Блоком deploy.resources.limits с полями cpus и memory на уровне сервиса - современный docker compose up применяет его без Swarm. Значения переводятся в лимиты Linux cgroups, и контейнер, упёршийся в предел по памяти, получает OOM-килл только сам, не задевая соседние сервисы.

Как обновить сервис в docker compose без риска для остальных?

docker compose pull скачивает новые образы по тегам из файла, а docker compose up -d пересоздаёт только те контейнеры, чья конфигурация реально изменилась. После обновления стоит сразу посмотреть docker compose logs -n 50 <сервис>, прежде чем считать обновление успешным - у остальных сервисов стека в это время ничего не происходит.

Как откатить контейнер на предыдущую версию образа?

Только если тег образа был закреплён на конкретную версию, а не оставлен на latest - у последнего нет предыдущей версии под тем же именем. При закреплённом теге откат - это правка тега в файле на предыдущее значение и снова docker compose up -d; если старый образ ещё не удалён локально, это занимает секунды.

Коротко

  • Compose хранит конфигурацию всего стека в одном файле вместо разрозненных docker run-команд - это единственный источник правды о том, что и как должно быть запущено.
  • Compose сам создаёт сеть, в которой сервисы видят друг друга по имени. Публиковать порт директивой ports нужно только тем сервисам, к которым обращаются снаружи Docker-сети - остальным это не требуется вообще.
  • Персистентные данные - только то, что явно вынесено томом. Bind mount на обычный каталог на диске - удобнее для бэкапов, потому что путь известен заранее.
  • Лимиты deploy.resources.limits.cpus/memory на каждый сервис работают в обычном docker compose up, без Swarm - и делают так, что зависший контейнер уносит с собой только себя.
  • Обновление - pull плюс up -d, откат - смена тега на предыдущий. Второе возможно только если вы не оставили сервис на latest.
  • Один VPS с Compose - не временное решение. Менять его на несколько серверов или оркестрацию стоит, когда упираетесь в суммарные ресурсы одной машины или в отказоустойчивость целого физического сервера, а не раньше.

Что дальше

Стек из этой статьи - каркас, а не готовый рецепт под конкретный набор сервисов. По порядку, куда двигаться дальше:

PUBLISHED
AUTHOR
HIP-HOSTING
LANGUAGES
EN · RU