Зачем это вообще нужно#
После переезда со всех compose-файлов на Komodo осталась одна незакрытая проблема: сами файлы docker-compose.yml по-прежнему лежат простыми текстовыми файлами на диске, без истории изменений, без бэкапа “что было вчера до того, как мои шаловливые ручки решили что-то подправить”, без единого общего репо, где хранится актуальная версия конфигурации. Правишь конфиг прямо на проде (да, да, у нас же хоумлаберов тоже свой прод есть, правда!?) — и если что-то пошло не так, откатываться не то чтобы не на что, а просто ты уже и не помнишь с чего начинал, и где та самая еще рабочая версия до того как ты все решил улучшить, что привело к полной неработоспособности системы.
Решение — завести собственный git-сервер (Forgejo) и связать его с Komodo так, чтобы:
- каждое изменение compose-файла фиксировалось в git;
git pushавтоматически подтягивался на сервер и передеплоивал только изменившиеся стэки;- всё это работало не только на основном хосте, но и на удалённых серверах (в моём случае — второй хост с Immich, второй хост с Nextcloud и т.д.).
Дальше — весь путь от установки Forgejo до рабочего GitOps-пайплайна, с граблями, на которые я наступил (кое-где повторно), чтобы вы не наступали.
Шаг 1. Устанавливаем Forgejo#
Forgejo — форк Gitea, лёгкий self-hosted git-сервер. Ставится обычным compose-стэком. У меня уже есть рабочая база данных Postgres на отдельном LXC-контейнере (управляю ей через pgAdmin), поэтому Forgejo подключил к нему, а не поднимал ещё один Postgres-контейнер рядом. Ниже я описываю, как это я делал у себя. В твоем случае, если у тебя нет единой базы данных, а есть отдельные для каждого стэка, то просто добавишь в компоуз данные для бд из официальной документации.
База данных#
В pgAdmin создаём роль и базу:
CREATE ROLE forgejo WITH LOGIN PASSWORD 'пароль';
CREATE DATABASE forgejo
OWNER forgejo
ENCODING 'UTF8'
TEMPLATE template0;Важный момент: если через GUI pgAdmin создать базу без явного указания TEMPLATE template0, можно получить ошибку new encoding (UTF8) is incompatible with the encoding of the template database (SQL_ASCII) — на некоторых серверах template1 исторически настроен в другой кодировке. Я, как обычно, учусь на своих ошибках и постоянно об этом забываю, но тебе то не так не надо?!
Docker-compose с метками для обратного прокси Traefik#
sudo nano docker-compose.yamlservices: # Объявляем список сервисов, которые должен создать Docker Compose.
server: # Имя сервиса внутри Compose. На него можно ссылаться как на "server".
image: codeberg.org/forgejo/forgejo:14 # Используем образ Forgejo версии 14 из реестра Codeberg.
container_name: forgejo # Явно задаём имя Docker-контейнера вместо автоматически сгенерированного Compose имени.
environment: # Передаём переменные окружения внутрь контейнера Forgejo.
- USER_UID=1000 # UID пользователя, от имени которого Forgejo работает с файлами в /data.
- USER_GID=1000 # GID группы, от имени которой Forgejo работает с файлами в /data.
- FORGEJO__database__DB_TYPE=postgres # Говорим Forgejo использовать PostgreSQL в качестве базы данных.
- FORGEJO__database__HOST=айпи_хоста:5432 # Адрес PostgreSQL-сервера и его порт. Вместо "айпи_хоста" указывается реальный IP-адрес.
- FORGEJO__database__NAME=forgejo # Имя базы данных PostgreSQL, созданной для Forgejo.
- FORGEJO__database__USER=forgejo # Имя пользователя PostgreSQL, от имени которого Forgejo подключается к базе.
- FORGEJO__database__PASSWD=${FORGEJO_DB_PASSWORD} # Пароль PostgreSQL берётся из переменной FORGEJO_DB_PASSWORD, определённой вне этого compose-файла.
restart: always # Docker будет автоматически перезапускать контейнер при его остановке, в том числе после перезагрузки хоста.
networks: # Подключаем контейнер к указанным Docker-сетям.
- proxy # Подключаем Forgejo к внешней сети proxy, через которую к нему обращается Traefik.
volumes: # Объявляем постоянные хранилища и bind-mounts контейнера.
- ./forgejo:/data # Каталог ./forgejo рядом с compose-файлом монтируется в /data контейнера; здесь хранятся данные Forgejo.
- /etc/localtime:/etc/localtime:ro # Передаём контейнеру системное локальное время хоста; режим ro запрещает запись в файл.
ports: # Публикуем порты контейнера непосредственно на Docker-хосте.
- '3000:3000' # Порт 3000 хоста перенаправляется на HTTP-порт Forgejo 3000.
- '222:22' # Порт 222 хоста перенаправляется на SSH-порт Forgejo 22.
labels: # Docker labels используются Traefik для автоматического обнаружения и настройки маршрутизации.
- "traefik.enable=true" # Разрешаем Traefik обрабатывать этот контейнер.
- "traefik.http.routers.forgejo.entrypoints=web" # Создаём HTTP-роутер Forgejo на entryPoint web, обычно это порт 80.
- "traefik.http.routers.forgejo.rule=Host(`forgejo.example.com`)" # Роутер будет срабатывать для запросов к указанному доменному имени.
- "traefik.http.middlewares.forgejo-https-redirect.redirectscheme.scheme=https" # Создаём middleware, который перенаправляет HTTP-запросы на HTTPS.
- "traefik.http.routers.forgejo.middlewares=forgejo-https-redirect" # Подключаем созданный middleware к HTTP-роутеру.
- "traefik.http.routers.forgejo-secure.entrypoints=websecure" # Создаём HTTPS-роутер на entryPoint websecure, обычно это порт 443.
- "traefik.http.routers.forgejo-secure.rule=Host(`forgejo.example.com`)" # HTTPS-роутер также обслуживает указанный домен.
- "traefik.http.routers.forgejo-secure.tls=true" # Включаем TLS для HTTPS-роутера.
- "traefik.http.routers.forgejo-secure.service=forgejo" # Указываем HTTPS-роутеру использовать сервис Traefik с именем forgejo.
- "traefik.http.services.forgejo.loadbalancer.server.port=3000" # Traefik должен отправлять запросы из сети proxy на порт 3000 контейнера Forgejo.
- "traefik.docker.network=proxy" # Явно указываем Traefik использовать Docker-сеть proxy для подключения к контейнеру.
security_opt: # Дополнительные параметры безопасности контейнера.
- no-new-privileges:true # Запрещаем процессам контейнера получать дополнительные привилегии через механизмы повышения прав.
networks: # Объявляем Docker-сети, используемые сервисами Compose.
proxy: # Описываем сеть с именем proxy.
external: true # Говорим Compose, что сеть уже создана отдельно и создавать её заново не нужно.sudo nano .envFORGEJO_DB_PASSWORD=парольОписание инфрастуктуры#
Схема работы этого Compose-файла выглядит так:
┌──────────────────────┐
│ Traefik │
│ :80/:443 │
└──────────┬───────────┘
│
Docker network
"proxy"
│
▼
┌──────────────────────┐
│ Forgejo │
│ :3000 │
│ :22 (SSH) │
└──────────┬───────────┘
│
./forgejo:/data
│
▼
┌──────────────────────┐
│ Данные Forgejo │
│ на Docker-хосте │
└──────────────────────┘
│
│ PostgreSQL
▼
┌──────────────────────┐
│ PostgreSQL │
│ <IP>:5432 │
│ database: forgejo │
└──────────────────────┘Два разных способа доступа к Forgejo#
HTTP-доступ пользователей идёт через Traefik:
Браузер
│
│ https://forgejo.example.com
▼
Traefik :443
│
│ Docker network "proxy"
▼
Forgejo :3000SSH-доступ Git работает иначе:
Git client
│
│ SSH :222
▼
Docker host :222
│
▼
Forgejo container :22Поэтому 3000:3000 и 222:22 здесь выполняют разные задачи: первый порт нужен для HTTP-доступа к Forgejo, второй — для Git по SSH.
Несколько важных замечаний#
ports и Traefik#
Если HTTP-доступ к Forgejo полностью организован через Traefik и сам Forgejo не должен быть доступен напрямую с сети, публикация:
ports:
- '3000:3000'может быть вообще не нужна. Traefik находится в той же Docker-сети proxy и может обращаться к forgejo:3000 напрямую.
При этом 222:22 нужен, если планируется использовать SSH-доступ к Git с хоста или из внешней сети.
Пароль PostgreSQL#
Строка:
- FORGEJO__database__PASSWD=${FORGEJO_DB_PASSWORD}не содержит пароль непосредственно в compose-файле. Docker Compose подставляет значение переменной FORGEJO_DB_PASSWORD из окружения или .env/другого используемого механизма передачи переменных.
Это предпочтительнее, чем хранить пароль непосредственно в YAML.
Внешняя сеть proxy#
Строка:
external: trueозначает, что сеть должна существовать до запуска Compose. Например:
docker network create proxyЕсли сеть не создана заранее, docker compose up -d завершится ошибкой.
Следующие команды#
docker compose up -d — и по нашему доменному имени https://forgejo.example.com открывается install wizard. Там всё стандартно, кроме одного поля, которое легко пропустить: Base URL. По умолчанию там http://localhost:3000/ — если не поправить на реальный домен, сломаются HTTPS-ссылки для клонирования и уведомления.
OpenID Connect сразу включил (пригодится позже для authentik), а self-registration оставил закрытой. Нам это не к чему, потому что у нас личный закрытый сервер.
Шаг 2. Монорепозиторий вместо репозитория на каждый стэк#
Вариантов организации git было два: отдельный репозиторий на каждый стэк/VM/LXC или один общий репозиторий со всеми compose-файлами. Выбрал второй — проще администрировать, один Repo-ресурс в Komodo, один вебхук, одна Procedure на все стэки разом. Безусловно есть определенное неудобство о чем ниже, но с ним можно жить. Да и всегда можно будет разнести по разным репо, что мне кажется проще, чем сливать разные репозитории в один.
Создаю в Forgejo пустой приватный репозиторий (без README и .gitignore через веб-интерфейс — они появятся из первого коммита) и превращаю в git-репозиторий прямо ту папку, где, уже который год (у меня), лежат все compose-файлы:
cd /home/твой_путь/docker
git init
git branch -M main
git remote add origin https://forgejo.example.com/user/docker-stacks.gitШаг 3. .gitignore — самая долгая часть#
Вот тут у меня началось самое интересное. Ну может и не самое, подумаешь с бубном потанцевал пару тройку часов. Первая наивная попытка git add -A в директории, где год копились данные 30+ контейнеров, выдаёт список из полутысячи с лишним файлов (не шутка или гипербола). Разбирать его вручную - не вариант, поэтому пошёл по пути “сначала находим самое тяжёлое и самое опасное, потом причёсываем остальное”.
Что искать в первую очередь — размер:
du -sh /home/твой_путь/docker/*/* 2>/dev/null | sort -rh | head -30Так определил гигабайты медиатек *arr-стэка (в основном это обложки от Lidarr и его же бекапы), базы уведомлений, кэши обложек аудиокниг — всё, что явно данные, а не конфигурация.
Что искать во вторую — секреты. Это оказалось важнее размера. В git add -A без разбора чуть не попали:
traefik/data/acme.json— приватный ключ аккаунта Let’s Encrypt и все выданные сертификаты (зачем нам это в нашем репо, правльно?);vaultwarden/data/— база менеджера паролей (это вообще самое важное);- собственная папка данных Forgejo (
forgejo/forgejo/) — сессии, JWT-ключ, и что забавно, сам git-репозиторий, который мы же и создаём, лежащий внутри самого себя; komodo/keys/иkomodo/mongo/data/— ключи Periphery и база самой Komodo;komodo/backups/— а вот это уже было по-настоящему неприятно: автоматические ежедневные бэкапы Komodo включают экспорт токенов git-провайдеров и API-ключей в гзипованном виде. Эта папка попала в один из ранних коммитов раньше, чем я это заметил (правильно наверно сказать подумал) — узнал только разобрав тело вебхука, который Forgejo прислала на push. Хорошо, что репозиторий приватный и путь до этого коммита короткий, но токены на всякий случай стоит перевыпустить. Мало ли что?
Итоговый .gitignore получился длинным — где-то по три-пять строк на каждый стэк с базой данных или кэшем. Общий принцип, который я для себя вывел:
В git должно попадать только то, что ты написал руками. Всё, что сгенерировал контейнер сам — базы, ключи, кэши, логи, сессии — не конфигурация, а данные. Данные версионировать бессмысленно (они меняются каждую секунду) и вредно (там могут быть секреты).
Рабочий цикл проверки был такой:
git reset
git add -A
git status # смотрим, что попало в staging
# находим лишнее → дописываем .gitignore → повторяемОтдельно всплыла проблема прав: часть файлов создана контейнерами от нестандартного UID, и обычный пользователь их даже прочитать не может — git add падает с Отказано в доступе. Такие пути тоже пришлось добавлять в .gitignore по мере появления ошибок.
Шаг 4. Первый коммит и push#
git config --global user.name "твое имя в репо"
git config --global user.email "you@example.com"
git commit -m "Initial import of docker stacks"
git remote set-url origin https://user:TOKEN@forgejo.example.com/user/docker-stacks.git
git push -u origin main
git remote set-url origin https://forgejo.example.com/user/docker-stacks.gitТокен для push — Personal Access Token из Forgejo (Settings → Applications → Generate New Token, права repository: Read and Write).
Шаг 5. Связываем с Komodo#
Здесь у Komodo есть два принципиально разных подхода:
- Перевести каждый Stack на git-режим — прописать в конфиге каждого стэка
repo/branch/run_directory. Минус: Komodo клонирует репозиторий в свою собственную директорию, и все относительные bind-mount пути (./data:/dataв compose-файлах) окажутся уже не там, где раньше — есть риск переехавших данных. - Завести отдельный ресурс Repo, который просто следит за репозиторием и умеет делать
git pull— при этом указать ему путь клонирования = та же директория, где уже лежат стэки. Тогдаgit pullпросто обновляет файлы на месте, а конфигурация Stack-ресурсов (files on server, абсолютные пути) вообще не меняется.
Выбрал второй — он безопаснее для уже работающей инфраструктуры.
Настройка:
- Settings → Git Accounts → добавить аккаунт для
forgejo.example.comс токеном. - Repos → New Repo → указать сервер, git-аккаунт, репозиторий, ветку
mainи Path = та самая директория со стэками. - Procedures → New Procedure → Stage 1:
Pull Repo→ Stage 2:Batch Deploy Stack If Changedс таргетом*(маска “все стэки”). - На странице Procedure — вкладка Webhooks, копируем готовый URL вида
https://komodo.example.com/listener/github/procedure/<id>/main. - В Forgejo: репозиторий → Settings → Webhooks → Add Webhook → тип Gitea (полностью совместим с “Github” auth style в Komodo) → вставляем URL, секрет, событие Push.
Шаг 6. Проверка#
echo "# test" >> some-stack/docker-compose.yaml
git add . && git commit -m "test webhook" && git pushДальше смотрим тело доставки в Recent Deliveries на стороне Forgejo (там виден весь payload — какие файлы добавлены/изменены) и состояние Repo/Stack-ресурсов в Komodo — у обновлённого стэка должен смениться commit hash и пройти передеплой.
Шаг 7. Масштабируем на второй хост#
У меня есть отдельный сервер с Immich, подключённый к Komodo как удалённый Periphery-агент. Заводить для него отдельный репозиторий не стал — тот же монорепозиторий, стэки этого хоста просто легли туда ещё одной подпапкой.
Единственная тонкость — при первом git checkout на втором хосте, где уже есть локальные файлы (в моём случае — свежесозданный .gitignore), git отказывается переключаться на ветку из-за риска быть перезаписанным (“would be overwritten by checkout”). Решается просто: убрать конфликтующий файл, он всё равно придёт из репозитория при checkout, а специфичные для этого хоста строки (в моём случае — immich/model-cache/, immich/postgres/, keys/) дописываются в общий .gitignore уже после переключения на ветку.
Дальше — тот же рецепт: отдельный Repo-ресурс в Komodo с путём на этом сервере, и второй Pull Repo в том же Stage 1 существующей Procedure. Batch Deploy с маской * подхватывает стэки любого сервера сам, никаких изменений в Stage 2 не потребовалось.
Итог#
- Все compose-файлы под git, с историей изменений;
git pushиз VS Code (а на Винде я пользуюсь именно им) автоматически прокатывается на все нужные серверы;- секреты и данные контейнеров осознанно исключены — в репозитории только то, что действительно нужно для восстановления инфраструктуры с нуля;
- расширяется на новые хосты без переделки схемы — просто ещё один Repo-ресурс в Komodo и ещё одна строка в Stage 1.



