Три 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.
Первый инстинкт после того, как заработал один контейнер - запустить второй тем же способом: скопировать команду, поменять образ и порт, дописать ещё один 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 как 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 создаст сам - подробнее в следующем разделе.А вот и сам /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 - по своим правилам, не обязательно тому контейнеру, который виноват.
Обновить сервис - значит скачать новую версию образа и пересоздать под неё контейнер. В 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.
sudo ss -ltnp | grep :80 и либо остановите тот процесс, либо смените порт у Caddy в compose-файле, если он должен сосуществовать с чем-то ещё.docker compose logs -n 50 caddy.DOMAIN задана и совпадает с реальным публичным адресом по https - без неё часть функциональности, завязанной на защищённый контекст браузера, попросту не включается.vaultwarden:80, а не localhost и не внешний домен. localhost внутри контейнера - это сам контейнер, а не хост и не соседи по сети.docker inspect <имя> --format '{{.HostConfig.Memory}}' и потребление через docker stats --no-stream: возможно, лимит просто занижен для реальной нагрузки, а не сервис ведёт себя неправильно.Честно говоря, для большинства персональных и небольших командных стеков этот потолок отодвинут очень далеко - на практике до него не доходит подавляющее большинство читателей этой статьи. Но он есть, и полезно знать его заранее, а не после инцидента.
Ситуация | Один VPS + Compose | Несколько VPS | Оркестрация (Swarm/Kubernetes) |
|---|---|---|---|
Сервисам систематически не хватает суммарных ресурсов одной машины | Не подходит | Подходит - разносите по разным VPS | Подходит, если серверов уже несколько |
Нужна изоляция между клиентскими проектами на уровне ядра, не только cgroups | Слабая изоляция | Полная изоляция отдельными VPS | Зависит от настройки, обычно тоже отдельные ноды |
Нужно пережить полную потерю одного физического сервера без даунтайма | Не переживёт - весь стек на одной машине | Нужна отдельная логика failover, сама по себе не даётся | Родная функциональность - под это и создавалась |
Достаточно пережить падение и перезапуск одного контейнера | Решается restart-политикой уже сейчас | Избыточно для этой задачи | Избыточно для этой задачи |
Если ваш случай - нижняя строка таблицы, а не верхние три, менять ничего не нужно. Один VPS с Compose и разумными лимитами на сервис - это не временное решение, которое стыдно показать senior-разработчику, а рабочая архитектура для абсолютного большинства self-hosted стеков.
Один YAML-файл описывает весь стек - образы, порты, тома, переменные окружения и связи между сервисами, - а не набор команд, разбросанных по истории терминала. Compose поднимает и останавливает весь стек одной командой и сам создаёт общую сеть с разрешением имён между сервисами, без ручной настройки.
Compose создаёт для проекта отдельную bridge-сеть и подключает к ней все сервисы. Внутри работает встроенный DNS Docker: имя сервиса из файла резолвится в его текущий внутренний IP, и это продолжает работать даже после пересоздания контейнера с новым адресом.
Нет. Директива ports нужна только тем сервисам, к которым обращаются снаружи Docker-сети - с хоста или из интернета. Сервисы, которые видит только другой контейнер этого же стека, получают доступ по внутренней сети на внутренний порт без публикации.
Данные без явного тома живут только в файловой системе контейнера и пропадают при его пересоздании. Персистентные данные - это то, что вынесено в volumes: именованный volume (хранится Docker'ом в /var/lib/docker/volumes/) или bind mount - обычный каталог на диске сервера. Бэкапить нужно эти каталоги.
Блоком deploy.resources.limits с полями cpus и memory на уровне сервиса - современный docker compose up применяет его без Swarm. Значения переводятся в лимиты Linux cgroups, и контейнер, упёршийся в предел по памяти, получает OOM-килл только сам, не задевая соседние сервисы.
docker compose pull скачивает новые образы по тегам из файла, а docker compose up -d пересоздаёт только те контейнеры, чья конфигурация реально изменилась. После обновления стоит сразу посмотреть docker compose logs -n 50 <сервис>, прежде чем считать обновление успешным - у остальных сервисов стека в это время ничего не происходит.
Только если тег образа был закреплён на конкретную версию, а не оставлен на latest - у последнего нет предыдущей версии под тем же именем. При закреплённом теге откат - это правка тега в файле на предыдущее значение и снова docker compose up -d; если старый образ ещё не удалён локально, это занимает секунды.
docker run-команд - это единственный источник правды о том, что и как должно быть запущено.ports нужно только тем сервисам, к которым обращаются снаружи Docker-сети - остальным это не требуется вообще.deploy.resources.limits.cpus/memory на каждый сервис работают в обычном docker compose up, без Swarm - и делают так, что зависший контейнер уносит с собой только себя.pull плюс up -d, откат - смена тега на предыдущий. Второе возможно только если вы не оставили сервис на latest.Стек из этой статьи - каркас, а не готовый рецепт под конкретный набор сервисов. По порядку, куда двигаться дальше: