Довели — снова. Поднимаем корпоративный Forgejo локально. Пошаговый гайд

Привет, это снова Марк. В прошлой статье я разбирался, как небольшой компании получить собственный корпоративный мессенджер: рассказал немножко про варианты, выбрал Matrix/Element и развернул его в облаке, спрятав за WireGuard VPN.
Сегодня решил продолжить строить компактную self-hosted-инфраструктуру и добавить поверх того же контура следующий обязательный сервис — собственный Git.
Зачем он, если есть тот же корпоративный GitHub, спросите вы? Аргументы для небольшой команды те же, что и с мессенджером: код — это главный актив, и хочется, чтобы он физически лежал на инфраструктуре, которую вы контролируете.
Внешние SaaS могут менять условия, ограничивать доступ по региональному признаку или просто оказаться недоступными в неподходящий момент. Плюс приватность: внутренние репозитории, тикеты и обсуждения кода не покидают ваш периметр.
Сразу зафиксирую границы решения. Сервис не публикуется наружу: доступ к веб-интерфейсу и Git-over-SSH идет только через WireGuard VPN. Наружу смотрит единственный порт — UDP WireGuard, все остальные сервисы доступны лишь из туннеля.
Такой подход еще и упрощает жизнь: публичные TLS-сертификаты от Let’s Encrypt внутри доверенного контура не нужны, для HTTPS на веб-морде хватит собственного внутреннего CA (так же, как в прошлой статье), а Git-over-SSH и без того шифруется поверх VPN.
Часть 1. Рынок self‑hosted Git
Часть 2. Подготовка инфраструктуры
Часть 3. Разворачиваем Forgejo
Часть 4. Поднимаем CI/CD
Часть 5. Демо на живом примере
Куда масштабироваться дальше
Вариант на вырост: сервисы на разных машинах
Заключение
Часть 1. Рынок self-hosted Git
Итак, что нам нужно:
-
хостинг Git-репозиториев с веб-интерфейсом;
-
pull request / code review как основной рабочий процесс;
-
простой CI на pull request: линтер, сборка, тесты — чтобы ветку нельзя было влить с красным статусом;
-
скромные требования к ресурсам;
-
низкая стоимость эксплуатации: отдельного DevOps-инженера в команде нет, обслуживать сервис будет тот, кто его поднял.

Чего не нужно: встроенных SAST/DAST-сканеров, сложных multi-stage CD-пайплайнов с environments, Kubernetes-интеграций и прочих вещей, ради которых обычно и берут тяжелые платформы.
Кандидаты
Рынок self-hosted Git-платформ на сегодня выглядит примерно так: тяжеловес GitLab CE, легкое семейство Gitea/Forgejo, минималистичный ветеран Gogs и нишевый, но очень интересный OneDev.
|
Решение |
CI/CD |
Registry |
Ресурсы |
Лицензия |
Сложность |
Maintenance |
|
GitLab CE |
Мощный встроенный CI/CD, эталон для сложных пайплайнов |
Есть container registry |
Тяжелый: минимум 4 ядра / 4 ГБ RAM, комфортно — 8 ГБ |
Ope-core, коммерческое управление (GitLab Inc.) |
Высокая: Omnibus-стек из PostgreSQL, Redis, Gitaly, Sidekiq, Puma |
Выше среднего: регулярные апгрейды тяжелого стека |
|
Gitea |
Gitea Actions (совместимы с GitHub Actions), стабильны с 1.19+ |
Есть (container/npm/PyPI и другие пакеты) |
Легкий: комфортно от 1–2 ГБ RAM |
MIT, но проект под контролем for-profit компании Gitea Ltd, есть open-core элементы |
Низкая: один бинарник + БД |
Низкий/средний |
|
Forgejo |
Forgejo Actions: workflows в .forgejo/workflows, исполняет отдельный runner |
Есть, как у Gitea — база для роста в сторону публикации артефактов |
Как у Gitea |
GPL v3+ (с v9.0), некоммерческое управление под Codeberg e.V. |
Как у Gitea |
Как у Gitea |
|
Gogs |
Нет встроенного, нужен внешний (Drone/Woodpecker) |
Ограниченно |
Очень легкий (~512 МБ) |
MIT, темп развития скромный |
Низкая |
Низкий, но и функций мало |
|
OneDev |
Встроенный CI/CD как часть платформы |
Есть сценарии для артефактов |
Средние (Java, от 2 ГБ) |
Опенсорс, специфичная экосистема |
Средняя |
Средний |
Что нам точно не подходит:
-
GitLab CE — избыточен по ресурсам и сложности эксплуатации для команды до 15 человек. Мы хотим маленькую ВМ и минимум обслуживания.
-
Gogs — слишком минималистичен: без встроенного CI пришлось бы тащить отдельный Drone/Woodpecker, а это лишний компонент в контуре.
-
OneDev — добротная платформа, но экосистема и сообщество заметно меньше, чем у Gitea/Forgejo; меньше готовых рецептов и интеграций.
Остается Forgejo (не Gitea) — при равной функциональности получаем некоммерческое и прозрачное управление, GPL-лицензию и более активную разработку. Если завтра у Gitea Ltd изменятся приоритеты, нас это не коснется. Ну и Forgejo Actions для нас — это топ, для пайплайна хватит с запасом.
Семейство Gitea/Forgejo и история форка
Небольшое лирическое отступление про выбранного кандидата.
Все началось с Gitea, легкой Go-платформы с одним бинарником, скромными аппетитами и привычным GitHub-подобным интерфейсом. В 2022 году вокруг проекта случился конфликт: домены и торговая марка Gitea были переданы коммерческой компании Gitea Ltd без согласования с сообществом. Часть мейнтейнеров в ответ создала форк — Forgejo, который развивается под эгидой немецкой некоммерческой организации Codeberg e.V. (она же управляет публичным хостингом Codeberg на сотни тысяч репозиториев).
Сначала Forgejo был «мягким» форком и регулярно подтягивал изменения из Gitea, но в феврале 2024 года команда объявила о переходе к полноценному hard fork, и теперь их кодовые базы расходятся. С версии 9.0 Forgejo сменил лицензию с MIT на GPL v3+. Важный практический момент для тех, кто уже сидит на Gitea: прозрачно апгрейднуться на Forgejo можно только с Gitea ≤ 1.22; после 1.23 миграция требует ручных операций с базой. То есть выбор надо делать на берегу.
Функционально на сегодня платформы практически идентичны — интерфейс, API, модель pull request. Различия в управлении проектом и темпе разработки: у Forgejo заметно больше коммитов и уникальных контрибьюторов за последние годы (по независимым подсчетам — примерно в 2,5 раза по обоим показателям), а решения принимает выборный совет, а не коммерческая компания.
Часть 2. Подготовка инфраструктуры
Облачная платформа
Как и в прошлый раз, разворачиваемся на платформе Cloud.ru Evolution.
Полезная особенность тарификации Evolution Compute — гарантированная доля vCPU. Машину можно взять с долей 10%, 30% или с полностью выделенными ядрами. При меньшей доле ядро делится с соседями, но берстить выше гарантированного уровня обычно можно. Для Git-сервера небольшой команды это идеальный профиль нагрузки: 95% времени сервис почти простаивает, пики приходятся только на push, клонирование и прогон CI.
Конфигурация ВМ
Стартовая рекомендация для Forgejo-инстанса:
-
2 vCPU с гарантированной долей 10% («Небольшая рабочая нагрузка» в калькуляторе).
-
4 ГБ RAM. Forgejo с PostgreSQL уместятся с большим запасом, останется и раннеру.
-
30 ГБ SSD. Система, Docker-образы и репозитории. Объем диска потом можно расширить.
-
Debian. Но подойдет и Ubuntu LTS, дальнейшие команды идентичны.
И еще одна оговорка: 4 ГБ хватает на сам контур (Forgejo + PostgreSQL + WireGuard + CoreDNS + Caddy), но прогон CI в Docker-in-Docker — самая прожорливая часть. Как только сборки начнут упираться в память, нужно будет поднимать до 4 vCPU / 8 ГБ либо выносить раннер на отдельную машину (про это в конце статьи). Апгрейд делается без переустановки.
Создаем виртуальную машину
Тут без подробностей, детальный walkthrough по консоли был в прошлой статье:
-
Через Меню → Инфраструктура → SSH-ключи добавляем свой ключ для доступа к серверу.
-
Инфраструктура → Виртуальные машины → Создать. Выбираем получить ВМ и создаем машину с образом Debian, конфигурацией 2 vCPU (доля 10%) / 4 ГБ, диск 30 ГБ.
-
Сеть — добавляем публичный IP, чтобы нормально заходить по SSH и иметь WireGuard endpoint.
-
Пока создается машина, переходим в Сеть → Группы безопасности. Тут выбираем ту, в которой сидит машина, и открываем нужные порты: 3422/TCP для нашего нестандартного SSH доступа и UDP 51820 для WireGuard. 22/TCP для первого подключения SSH уже будет настроен.

-
Донастраиваем машину. Я обновил себе все пакеты, доставил neovim и tmux, подтянул удобные конфиги, перенастроил SSH. Простой набор для минимальной рабочей и комфортной системы. Обычно я ставлю еще ufw-файрволл, но тут по сути им выступают группы безопасности, так что не стал заморачиваться.
Сетевая модель: все за WireGuard
Как и в прошлый раз, у нашего Git-сервера не будет публичного IP. Схема такая:
-
WireGuard-шлюз, CoreDNS и Forgejo живут на одной машине. У нее есть публичный адрес, и наружу открыты только UDP-порт WireGuard 51820 и SSH порт 3422.
-
Сам Forgejo, его веб-интерфейс, CoreDNS и Caddy слушают только на адресе WireGuard-интерфейса (wg0, 10.8.0.1) — из интернета к ним не достучаться.
-
Сотрудники подключаются к VPN и ходят на Forgejo по внутреннему имени git.team.internal — в браузере по HTTPS (через Caddy) и по Git-over-SSH.
-
Исходящий трафик (apt, образы Docker) идет напрямую через публичный IP машины. Все входящее, кроме порта WireGuard, режется группой безопасности облака.
Я сознательно совмещаю сетевой периметр и приложение на одной машине: для команды до 15 человек это проще и дешевле, чем держать отдельный шлюз. Если нагрузка вырастет или понадобится чистое разделение ответственности — WireGuard с CoreDNS выносятся на отдельную маленькую ВМ, а Forgejo остается в приватной подсети уже без публичного IP. Логика настройки от этого не меняется, отличается только то, на каком адресе все слушает.
Внутри туннеля трафик уже зашифрован самим WireGuard, так что формально на Forgejo можно ходить и по голому HTTP. Но браузеры все настойчивее ругаются на HTTP-страницы с полями ввода пароля, а часть веб-функционала ожидает защищенного контекста (secure context).
Поэтому HTTPS мы все же поднимаем — без внешних удостоверяющих центров: на узле работает общий Caddy (он же фронтит Element из первой статьи), который выступает собственным внутренним CA. Как подключить к нему Forgejo — объяснил ниже, в разделе «HTTPS через Caddy с внутренним CA».
Ставим Docker
Докер ставим тихо, не спеша черемша и стандартно, по официальной документации:
# Add Docker's official GPG key:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Add the repository to Apt sources:
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
WireGuard за пять минут
Полный разбор установки wg-easy со скриншотами тоже есть в первой статье. Здесь сжатая выжимка, чтобы можно было не прыгать между статьями.
/opt/docker/wg-easy/compose.yml
services:
wg-easy:
image: ghcr.io/wg-easy/wg-easy:15
container_name: wg-easy
network_mode: host
environment:
- INSECURE=true
- DISABLE_IPV6=true
volumes:
- etc_wireguard:/etc/wireguard
- /lib/modules:/lib/modules:ro
cap_add:
- NET_ADMIN
- SYS_MODULE
devices:
- /dev/net/tun:/dev/net/tun
restart: unless-stopped
volumes:
etc_wireguard:
Включаем форвардинг и поднимаем контейнер:
sudo tee /etc/sysctl.d/90-wireguard.conf << 'EOF'
net.ipv4.ip_forward=1
net.ipv4.conf.all.src_valid_mark=1
EOF
sudo sysctl --system
cd /opt/docker/wg-easy && docker compose up -d
Web UI wg-easy слушает на 51821 и наружу закрыт, поэтому первый заход — через SSH-туннель:
ssh -L 51821:127.0.0.1:51821 -p <SSH-порт> user@<PUBLIC_IP>
Затем открываем http://127.0.0.1:51821.
В мастере первого запуска и в настройках задаем ключевое:
-
Host — публичный IP сервера, Port — 51820.
-
Разрешенные IP клиентов (AllowedIPs) — 10.8.0.0/24: в туннель уходит только внутренний трафик, а не весь интернет сотрудника.
-
DNS — 10.8.0.1: это адрес нашего CoreDNS (см. ниже), клиенты получат его автоматически при подключении.
-
Хуки PostUp/PostDown — убираем NAT, оставляем только доступ к внутренним ресурсам:
# PostUp
iptables -A FORWARD -i wg0 -o wg0 -j ACCEPT
# PostDown
iptables -D FORWARD -i wg0 -o wg0 -j ACCEPT
Внутренний DNS на CoreDNS
CoreDNS отвечает на DNS-запросы клиентов VPN и резолвит имена *.team.internal в адрес сервера внутри туннеля (10.8.0.1). Если CoreDNS уже поднят, достаточно добавить одну A-запись для Git в существующую зону team.internal:
/etc/coredns/db.team.internal
git IN A 10.8.0.1 ; Forgejo
ВАЖНО: увеличьте serial в SOA, иначе кеш/secondary не подхватят правку. Дальше перезапускаем сервис.
sudo systemctl restart coredns
Если это новая машина, то поднимаем CoreDNS по инструкции из первой статьи. Минимум, который нужен — Corefile, привязанный к VPN-интерфейсу, и зона с парой записей:
/etc/coredns/Corefile
.{
bind 10.8.0.1 # слушаем только на WireGuard-интерфейсе
file /etc/coredns/db.team.internal team.internal
forward . 8.8.8.8 1.1.1.1 # upstream для остальных имен
cache 30
errors
}
/etc/coredns/db.team.internal
$ORIGIN team.internal.
@ IN SOA ns.team.internal. admin.team.internal. (
2026010101 3600 600 86400 60 )
@ IN NS ns.team.internal.
ns IN A 10.8.0.1
git IN A 10.8.0.1
Очень важно иметь запись forward . 8.8.8.8 1.1.1.1, потому что таким образом мы позволим клиентам с подключенным WireGuard резолвить и наши внутренние ресурсы, и публичный интернет.
Часть 3. Разворачиваем Forgejo
Минимальный docker-compose
Вся инсталляция — два контейнера: сам Forgejo и PostgreSQL. Для нашей команды этого пока достаточно. Redis, внешние кеши и прочий обвес пока не нужны.
Создаем каталог /opt/docker/forgejo и кладем туда compose.yml:
/opt/docker/forgejo/compose.yml
networks:
forgejo:
services:
server:
image: data.forgejo.org/forgejo/forgejo:15
container_name: forgejo
restart: unless-stopped
depends_on:
- db
networks:
- forgejo
environment:
- USER_UID=1000
- USER_GID=1000
- FORGEJO__database__DB_TYPE=postgres
- FORGEJO__database__HOST=db:5432
- FORGEJO__database__NAME=forgejo
- FORGEJO__database__USER=forgejo
- FORGEJO__database__PASSWD=${DB_PASSWORD}
- FORGEJO__server__ROOT_URL=https://git.team.internal/ # наружу отдает Caddy
- FORGEJO__server__SSH_DOMAIN=git.team.internal
- FORGEJO__server__SSH_PORT=6722
- FORGEJO__actions__ENABLED=true
volumes:
- ./forgejo-data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "10.8.0.1:3000:3000" # внутренний HTTP; наружу Caddy отдает HTTPS
- "10.8.0.1:6722:22" # git over ssh, только через VPN
db:
image: postgres:18-alpine
container_name: forgejo-db
restart: unless-stopped
networks:
- forgejo
environment:
- POSTGRES_USER=forgejo
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=forgejo
volumes:
- ./postgres-data:/var/lib/postgresql
БД выносим в .env рядом с компоузом:
/opt/docker/forgejo/.env
DB_PASSWORD=<сгенерировать: openssl rand -hex 24>
Пара слов о структуре. Все данные Forgejo (репозитории, конфиг app.ini, вложения, LFS) живут в одном каталоге ./forgejo-data. Это сильно упрощает бэкап. Переменные вида FORGEJO__секция__КЛЮЧ транслируются в соответствующие параметры app.ini при первом запуске, так что конфигурация воспроизводима из одного файла.
ROOT_URL и SSH_DOMAIN
Два параметра, на которых спотыкаются чаще всего:
-
ROOT_URL — внешний адрес, по которому пользователи открывают веб-интерфейс. Именно из него Forgejo генерирует все ссылки, в том числе clone-URL по HTTP(S). У нас это https://git.team.internal/. TLS терминирует Caddy (см. раздел ниже). Оставите localhost — получите неработающие ссылки у всей команды.
-
SSH_DOMAIN и SSH_PORT — домен и порт, которые подставляются в SSH-clone-URL. У нас SSH Forgejo проброшен на порт 6722 хоста (3422 оставлен для администрирования самой ВМ), поэтому clone-строка будет вида ssh://git@git.team.internal:6722/team/repo.git.
Важно: порт 6722 опубликован только на адресе WireGuard-интерфейса (10.8.0.1) и в облачную security group не выносится — git-over-ssh, как и веб-интерфейс, доступен исключительно из VPN-туннеля. Наружу по-прежнему смотрят только UDP 51820 (WireGuard) и TCP 3422 (админский SSH).
HTTPS через Caddy с внутренним CA
Здесь у нас получается первая развилка по сравнению с Matrix-стеком из первой статьи. Там Caddy жил внутри docker-compose и проксировал Synapse и Element по именам контейнеров. Но Caddy у нас один на весь сервер: он уже обслуживает chat.team.internal и element.team.internal, теперь добавляется git.team.internal, а завтра может появиться что-то еще — в том числе нативные сервисы на хосте, до которых контейнерному Caddy тянуться неудобно.
Запихивать общий ingress в чей-то один compose — значит раздувать его и плодить связанность между несвязанными стеками. Поэтому Caddy выносим в отдельный нативный сервис — единую точку входа для всего узла. Заодно нативный Caddy чисто решает проблему привязки к адресу VPN-интерфейса (об этом ниже), которая у контейнера с публикацией порта на wg0 решается не всегда предсказуемо.
Ставим Caddy из официального apt-репозитория — он приносит и бинарь, и systemd-юнит, и пользователя caddy:
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg
sudo chmod o+r /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install caddy
Конфиг лежит в /etc/caddy/Caddyfile, данные и ключи внутреннего CA — в /var/lib/caddy. Описываем все сайты узла в одном файле:
/etc/caddy/Caddyfile
{
local_certs # все сайты обслуживает внутренний CA
pki {
ca local {
name "Team Internal CA"
root_cn "Team Internal Root CA"
}
}
}
chat.team.internal {
bind 10.8.0.1 # слушаем только на WireGuard-интерфейсе
reverse_proxy 127.0.0.1:8008 # Synapse: потребитель только Caddy → loopback
}
element.team.internal {
bind 10.8.0.1
reverse_proxy 127.0.0.1:8080 # Element Web
}
git.team.internal {
bind 10.8.0.1
reverse_proxy 10.8.0.1:3000 # Forgejo: ходит еще и раннер → адрес wg0
}
Раз Caddy вышел из compose Matrix-стека, по именам контейнеров (synapse, element-web) он до них больше не достучится, нужен сетевой доступ через хост. Дальше работает простое правило: каждый сервис публикуется на самом узком адресе, до которого дотягиваются все его потребители. Поэтому здесь адреса публикации у сервисов разные.
Synapse и Element потребляет только локальный Caddy, поэтому публикуем их на loopback: в compose Matrix добавьте ports вида 127.0.0.1:8008:8008 (Synapse) и 127.0.0.1:8080:80 (Element Web). Loopback тут вдвойне удобен: наружу ничего не торчит, и нет зависимости от порядка поднятия — адрес 127.0.0.1 существует всегда, в отличие от 10.8.0.1, который появляется только после старта wg0.
Forgejo же потребляет не только Caddy, но и раннер — а это контейнер (в перспективе и вовсе отдельная ВМ), которому хостовый loopback недоступен: его 127.0.0.1 — это loopback самого контейнера. Поэтому Forgejo публикуем на адресе WireGuard-интерфейса (10.8.0.1:3000) — единственном, который виден всем его клиентам сразу. Плата за это — та же зависимость от порядка старта, что и у Caddy (контейнер с публикацией на 10.8.0.1 нужно перезапускать после поднятия wg0. restart: unless-stopped это вытягивает).
Есть тонкость с порядком запуска. Caddy слушает на 10.8.0.1:443, а адрес 10.8.0.1 принадлежит интерфейсу wg0, который поднимает контейнер wg-easy. На ребуте Caddy может стартовать раньше, чем появится wg0, и упасть с cannot assign requested address. Для нативного процесса это лечится так: разрешаем bind на еще не поднятый адрес и привязываем старт к готовности docker/сети.
echo 'net.ipv4.ip_nonlocal_bind=1' | sudo tee /etc/sysctl.d/91-caddy.conf
sudo sysctl --system
sudo systemctl edit caddy # в drop-in вписать:
# [Unit]
# After=docker.service network-online.target
# Wants=network-online.target
sudo systemctl reload caddy
Переезжаете с контейнерного Caddy? Если на этом сервере уже работал Caddy из первой статьи, то уже есть корневой CA, который роздан на устройства команды.
Чтобы НЕ перевыпускать корень и не обходить все устройства заново — перенесите старый CA в нативный Caddy ДО первого старта: скопируйте каталог authorities/local (в нем root.crt, root.key и intermediate) из тома старого Caddy в /var/lib/caddy/.local/share/caddy/pki/authorities/local, выставьте владельца caddy:caddy и только потом запускайте сервис. Сертификат останется прежним, на устройствах переустанавливать ничего не нужно.
Если же CA создается с нуля — после первого старта достаем корневой сертификат, чтобы раздать его на устройства команды:
sudo cp /var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt
./team-root-ca.crt
sudo chown "$USER" team-root-ca.crt
Дальше team-root-ca.crt ставим в доверенные корни на каждом устройстве — команды те же, что и в первой статье:
-
Linux — в /usr/local/share/ca-certificates + update-ca-certificates.
-
macOS — security add-trusted-cert.
-
Windows — Import-Certificate в Cert:LocalMachineRoot.
-
На iOS/Android — через профиль и «Установить сертификат».
-
Браузеры с собственным хранилищем (Firefox) импортируют корень отдельно.
Сертификат живет 10 лет — операция разовая. Если нужно, чтобы и сам сервер доверял своему CA (например, для curl-проверок), делаем sudo caddy trust.
ВАЖНО для CI: внутренний CA — та же грабля для раннера, что и при контейнерном Caddy. Контейнеры джоб про наш корневой сертификат ничего не знают, поэтому actions/checkout по https://git.team.internal упал бы на проверке сертификата. Мы этого избегаем тем, что раннер ходит в Forgejo по внутреннему HTTP (http://10.8.0.1:3000), минуя Caddy и CA целиком (см. раздел про регистрацию раннера). То, что Caddy теперь нативный, на CI-путь не влияет — он все так же не задевает HTTPS.
Как сделать лучше на будущее (и себе на заметку). Возня с раздачей корневого сертификата на каждое устройство и обходные пути для CI — это плата за самоподписанный CA. Убирается все одним ходом: настоящий публично доверенный сертификат на реальный (под)домен, который вы контролируете, через ACME с DNS-01 challenge — машину наружу при этом выставлять не нужно, валидация идет через TXT-записи.
Для нативного Caddy плагин нужного DNS-провайдера добавляется пересборкой бинаря через xcaddy (или берется готовый билд). Сертификату Let’s Encrypt доверяют все браузеры и все контейнеры по умолчанию, так что отпадает и установка CA на устройства, и пляски с CA внутри раннера. Для следующей итерации инфраструктуры это, пожалуй, первое, что стоит сделать.
Первый запуск
cd /opt/docker/forgejo
docker compose up -d
docker compose logs -f server # дождаться строки про Listen: http://0.0.0.0:3000
Открываем https://git.team.internal (из-под VPN). Если корневой сертификат на устройстве еще не установлен, браузер ругнется на недоверенный TLS — это ожидаемо.
Вернитесь к разделу «HTTPS через Caddy с внутренним CA», достаньте team-root-ca.crt и установите его. После этого Forgejo встретит мастером первичной настройки: параметры БД уже подставлены из переменных окружения — проверяем и не трогаем. Снизу в секции администратора создаем первую учетную запись — она автоматически получает права администратора инстанса. Жмем Install Forgejo.


Сразу после установки стоит закрутить пару гаек в ./forgejo-data/gitea/conf/app.ini:
-
DISABLE_REGISTRATION = true в секции [service] — регистрация только через администратора;
-
REQUIRE_SIGNIN_VIEW = true — контент виден только авторизованным.
Для корпоративного инстанса в закрытом контуре это разумные дефолты.
Проверяем базовые сценарии
Прежде чем идти дальше, убеждаемся, что code hosting работает как надо.
Создаем организацию team и в ней тестовый репозиторий.



Добавляем свой SSH-ключ в профиль, клонируем: git clone ssh://git@git.team.internal:6722/team/test.git.

Коммитим файл, пушим, видим его в веб-интерфейсе.

Создаем ветку, меняем файл, открываем pull request, оставляем комментарий в review, мержим.


Если все четыре пункта прошли — у нас есть полноценный приватный Git-сервис. Теперь научим его запускать CI.
Бэкапы (коротко, но обязательно)
Git-сервер — это то место, потеря которого больно бьет по всей команде, поэтому минимальный бэкап настраиваем сразу: дамп PostgreSQL плюс архив каталога данных. Простейший cron-вариант на хосте:
backup.sh
#!/bin/sh
# /opt/docker/forgejo/backup.sh -- в cron на ночное время
set -e
cd /opt/docker/forgejo
docker compose exec -T db pg_dump -U forgejo forgejo | gzip > /backup/forgejo-db-$(date +%F).sql.gz
tar czf /backup/forgejo-data-$(date +%F).tar.gz forgejo-data/
Каталог /backup дальше синхронизируем в хранилище с любым S3-клиентом (rclone, s3cmd), у Cloud.ru для долгого хранения у облака есть «ледяной» класс. Подробный разбор стратегии бэкапов оставим за рамками статьи.
Часть 4. Поднимаем CI/CD
Как устроены Forgejo Actions
Forgejo Actions — встроенная в Forgejo CI/CD-система, по модели совместимая с GitHub Actions. Если вы хоть раз писали workflow для GitHub — переучиваться не придется. Работают многие готовые экшены вроде actions/checkout. Отличия: workflow-файлы лежат в каталоге .forgejo/workflows репозитория, а сами задачи выполняет отдельный демон — Forgejo Runner.
Раннер сам опрашивает Forgejo по HTTP, забирает задачи, исполняет их в Docker-контейнерах и отправляет обратно логи и статус.
Важные следствия из этой архитектуры:
-
Раннеру не нужны входящие порты. Он живет где угодно, лишь бы дотягивался до Forgejo.
-
Раннер можно (и под нагрузкой нужно) выносить на отдельную машину. Официальная документация прямо не рекомендует держать его на одной машине с инстансом. Для нашего демомасштаба допустимо разместить его рядом, но понимать trade-off обязательно: тяжелая сборка может отъесть CPU у самого Git-сервера.
-
Один инстанс может обслуживаться несколькими раннерами с разными метками (labels) — так масштабируется CI.
Включаем Actions
Actions включаются на двух уровнях. На уровне инстанса мы уже все сделали переменной FORGEJO__actions__ENABLED = true в compose-файле. На уровне репозитория: проверяем, что в Settings → Units (Overview) юнит Actions включен. Для новых репозиториев он обычно активен по умолчанию, если Actions разрешены на инстансе.

Разворачиваем раннер
Раннер тоже поднимем через Docker Compose, по схеме из официальной документации — с отдельным Docker-in-Docker-демоном. Так контейнеры джоб создаются внутри изолированного DinD, а не через хостовый /var/run/docker.sock. Монтировать сокет хоста в раннер — популярный, но небезопасный шорткат (любой workflow получает фактически root на хосте).
Создаем /opt/docker/forgejo-runner:
/opt/docker/forgejo-runner/compose.yml
services:
docker-in-docker:
image: docker:dind
container_name: dind
privileged: true
command: ['dockerd', '-H', 'tcp://0.0.0.0:2375', '--tls=false']
restart: unless-stopped
runner:
image: data.forgejo.org/forgejo/runner:12
container_name: forgejo-runner
depends_on:
- docker-in-docker
environment:
DOCKER_HOST: tcp://docker-in-docker:2375
user: 1001:1001
volumes:
- ./data:/data
restart: unless-stopped
command: 'forgejo-runner daemon --config runner-config.yml'
Контейнер раннера запускается от имени непривилегированного пользователя с uid/gid 1001 — это зашито в образ. Docker создает bind-mount директории с владельцем root, поэтому передаем их раннеру вручную. chown работает с числовыми uid без необходимости создавать пользователя на хосте:
cd /opt/docker/forgejo-runner
mkdir -p data/.cache
sudo chown -R 1001:1001 data
sudo chmod 775 data/.cache
sudo chmod g+s data/.cache
# Генерируем файл конфигурации раннера
docker run --rm data.forgejo.org/forgejo/runner:12
forgejo-runner generate-config > data/runner-config.yml
sudo chown 1001:1001 data/runner-config.yml
Регистрация раннера
Начиная с версии 12, команда forgejo-runner register устарела. Учетные данные теперь прописываются в конфиг раннера напрямую.
Шаг 1. В админке Forgejo идем в Site Administration → Actions → Runners → Create new runner. Forgejo выдает UUID и токен — копируем оба значения.

Шаг 2. Добавляем в data/runner-config.yml секцию подключения и labels. Labels связывают значение runs-on из workflow с конкретным Docker-образом — именно здесь указывается, в чем будет исполняться джоба:
runner:
labels: ["docker:docker://ghcr.io/catthehacker/ubuntu:act-latest"]
server:
connections:
forgejo:
url: http://10.8.0.1:3000/ # внутренний HTTP, НЕ https-имя -- см. примечание ниже
uuid: <UUID из админки>
token: <токен из админки>
Обратите внимание на url: мы указываем внутренний HTTP-адрес (http://10.8.0.1:3000), а не https://git.team.internal.
Это сознательно — так CI-путь не упирается в наш внутренний CA (контейнеры джоб ему не доверяют, и checkout по HTTPS упал бы на проверке сертификата). Если же в вашей версии Forgejo checkout все равно тянет адрес из ROOT_URL, останется либо смонтировать корневой сертификат в контейнеры раннера, либо перейти на публично доверенный сертификат.
Про метку docker:docker://ghcr.io/catthehacker/ubuntu:act-latest: это де-факто стандартный образ для act-совместимых раннеров — Ubuntu с предустановленными Node.js, Python, git и прочим базовым инструментарием. Важный нюанс: JS-экшены вроде actions/checkout исполняет Node.js внутри контейнера джобы. Если выбрать «чистый» образ без Node (например, python:3.12-slim), checkout молча упадет. Это самые частые грабли при первом знакомстве с Gitea/Forgejo Actions.
Шаг 3. Запускаем раннер:
docker compose up -d
docker compose logs -f runner # ждем "Starting runner daemon"
В админке Forgejo на странице Runners раннер должен появиться со статусом Idle. CI готов принимать задачи.

Часть 5. Демо на живом примере
Корпоративный код показывать нельзя, поэтому демонстрировать пайплайн будем на нарочито простом проекте — крошечном калькуляторе на Python. Один модуль, несколько функций, тесты на pytest, линтер ruff. Маленький размер тут достоинство: workflow остается коротким и читаемым, а паттерн один в один переносится на реальные проекты любого размера.
В тот же наш репозиторий Mighty-Team/testing добавляем файлы:
testing/
├── .forgejo/
│ └── workflows/
│ └── ci.yaml
├── calc.py
├── tests/
│ └── test_calc.py
└── pyproject.toml
Сам калькулятор — библиотека плюс минимальный CLI:
import argparse
def add(a: float, b: float) -> float:
return a + b
def sub(a: float, b: float) -> float:
return a - b
def mul(a: float, b: float) -> float:
return a * b
def div(a: float, b: float) -> float:
if b == 0:
raise ZeroDivisionError("b must be non-zero")
return a / b
OPS = {"add": add, "sub": sub, "mul": mul, "div": div}
def main() -> None:
parser = argparse.ArgumentParser(prog="tiny-calc")
parser.add_argument("op", choices=OPS)
parser.add_argument("a", type=float)
parser.add_argument("b", type=float)
args = parser.parse_args()
print(OPS[args.op](args.a, args.b))
if name == "__main__":
main()
tests/test_calc.py
import pytest
from calc import add, div, mul, sub
def test_add():
assert add(2, 3) == 5
def test_sub():
assert sub(10, 4) == 6
def test_mul():
assert mul(3, 4) == 12
def test_div():
assert div(10, 4) == 2.5
def test_div_by_zero():
with pytest.raises(ZeroDivisionError):
div(1, 0)
pyproject.toml
[project]
name = "tiny-calc"
version = "0.1.0"
requires-python = ">=3.11"
[tool.pytest.ini_options]
pythonpath = ["."]
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "W", "B"]
Workflow
А вот и весь наш CI — один файл .forgejo/workflows/ci.yaml:
.forgejo/workflows/ci.yaml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: |
python3 -m venv .venv
.venv/bin/pip install --quiet --upgrade pip ruff
- name: Lint (ruff)
run: .venv/bin/ruff check .
test:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: |
python3 -m venv .venv
.venv/bin/pip install --quiet --upgrade pip pytest
- name: Tests (pytest)
run: .venv/bin/pytest -v
Разберем по строкам.
Триггеры on. Пайплайн запускается на каждый push в main и на каждый pull request (включая обновления веток PR).
runs-on: docker. Та самая метка, которую мы прописали раннеру в конфиге: джоба поедет в контейнер catthehacker/ubuntu:act-latest.
Два независимых job’а — lint и test — запускаются параллельно: если оба упадут, в PR сразу видны оба результата, не нужно чинить линтер вслепую, чтобы добраться до ошибок тестов. Каждый job ставит только нужные зависимости. В компилируемых проектах здесь был бы третий job — build.
Пушим все это в main — и на вкладке Actions репозитория появляется первый прогон. Если он зеленый, переходим к главному номеру программы.


Сценарий: красный PR, зеленый PR
Изобразим типичный рабочий момент. Коллега добавляет функцию процентов и делает новую ветку:
git checkout -b feature/percent
фрагмент calc.py (намеренно неаккуратный)
import sys # забыли убрать -- F401: unused import
def percent(a: float,p: float) -> float:
return a*p/100
OPS = {"add": add, "sub": sub, "mul": mul, "div": div, "pct": percent}
И тест, который писался до того, как определились с семантикой:
фрагмент tests/test_calc.py
def test_percent():
assert percent(200, 10) == 10 # ожидание неверно: должно быть 20
Пушим ветку, открываем pull request в main. Раннер просыпается, и через минуту в PR — красный статус. В логах видно сразу два сорта проблем: ruff ругается на неиспользуемый импорт (F401), а pytest показывает assert 20.0 == 10. Forgejo рисует статус прямо в интерфейсе PR, и пока он красный, кнопка слияния сигнализирует: вливать рано.



Чиним: убираем ненужный импорт, исправляем ожидание в тесте на == 20, пушим в ту же ветку. Workflow перезапускается автоматически — и вот он, зеленый пайплайн и понятный сигнал всей команде: ветку можно мержить.

Финальный штрих — в настройках ветки main (Settings → Branches → Branch protection) включаем защиту и требуем прохождения статус-чеков lint и test перед слиянием. Теперь «красный» код в main не попадет даже при большом желании.
Стоит проговорить границы: Forgejo Actions — совместимая реализация, а не полная копия GitHub Actions. Часть возможностей отсутствует или работает иначе (например, concurrency groups), не все экшены из маркетплейса заведутся без напильника. Для пайплайнов уровня «линт-сборка-тесты-деплой» этого не замечаешь. Но если упретесь — список отличий есть в документации Forgejo.
Куда масштабироваться дальше
Минимальный стек работает, но у него солидный запас роста — и весь он включается постепенно, без смены платформы.
-
Матрица сборок. Стандартная конструкция strategy: matrix из мира GitHub Actions работает и здесь. Один workflow прогоняет тесты на нескольких версиях Python (или против нескольких окружений) параллельными джобами. Для библиотек — мастхэв.
-
Сборка и публикация Docker-образов. У Forgejo есть встроенный package registry, включая container registry: следующая логичная стадия после тестов — собрать образ и запушить его в git.team.internal под учеткой CI. Деплой при этом сводится к docker compose pull && up -d на целевом хосте.
-
Отдельная ВМ под runner. Когда CI начнет конкурировать с самим Forgejo за CPU и память (станет заметно по тормозам веб-интерфейса во время сборок) — раннер переезжает на отдельную машину без единого изменения в workflow. Регистрируем его на новом хосте тем же токеном и гасим старый. Кстати, раннеров может быть несколько — нагрузка размажется сама.
-
Deployment stages. Джобы с needs: выстраиваются в конвейер: tests → build → deploy-staging → deploy-prod, с ручным подтверждением там, где критично.
-
Уведомления в Matrix/Element. Помните мессенджер из прошлой статьи? У Forgejo есть встроенный тип вебхука Matrix: Settings → Webhooks → Add webhook → Matrix. Указываем адрес homeserver, room ID и access token бота — и события push, PR и review приходят прямо в комнату команды. Два наших self-hosted-сервиса смыкаются в единый внутренний контур: код, ревью и обсуждение живут в одной экосистеме, не покидая периметра.

Вариант на вырост: сервисы на разных машинах
Все описанное живет на одной машине, и для нашей команды этого хватает с запасом. Но рано или поздно появляется причина разнести сервисы: становится тесно, хочется изолировать blast radius (упавший Forgejo не должен ронять мессенджер), или у сервисов расходятся профили нагрузки и графики обновлений.
Покажу, как разложить контур на несколько машин — меняются только адреса.
Целевая топология: одна машина-гейтвей с публичным IP и WireGuard-эндпоинтом, остальные — без публичных адресов, в приватной сети облака.

Маршрутизация: гейтвей вместо all-in-one
Бэкенды в WireGuard не входят — они сидят в приватной подсети облака (10.0.0.0/24), а гейтвей становится маршрутизатором между туннелем и этой подсетью. У него два «плеча»: wg0 (10.8.0.1) со стороны VPN и приватный интерфейс (например, 10.0.0.1) со стороны бэкендов.
На клиентах в AllowedIPs по-прежнему достаточно 10.8.0.0/24: до сервисов они ходят не напрямую, а через единый вход на гейтвее, поэтому маршрут приватной подсети в туннель прокидывать не нужно. CoreDNS остается на гейтвее и все так же резолвит *.team.internal в 10.8.0.1 — для клиента точка входа не меняется.
Где сидит Caddy
Caddy переезжает на гейтвей и закрепляет за ним роль единой точки входа уже для всего контура, а не одной машины. Он по-прежнему слушает на 10.8.0.1:443, терминирует HTTPS и держит внутренний CA — меняется ровно одна вещь: апстримы reverse_proxy теперь смотрят на приватные адреса бэкендов, а не на localhost:
chat.team.internal {
bind 10.8.0.1
reverse_proxy 10.0.0.11:8008 # Synapse на отдельной ВМ
}
git.team.internal {
bind 10.8.0.1
reverse_proxy 10.0.0.12:3000 # Forgejo на отдельной ВМ
}
Правило «публикуемся на самом узком адресе, до которого дотягиваются потребители» работает и здесь: внутри приватной подсети сервисы отдают plain HTTP, а TLS терминирует Caddy на гейтвее. Никаких сертификатов на бэкендах — внутренний CA по-прежнему живет в одном месте, и раздача корня на устройства команды не меняется вовсе.
git-over-ssh через гейтвей
HTTP(S) Caddy проксирует на уровне L7, а git-over-ssh — это голый TCP, через HTTP-прокси он не пойдет. Имя git.team.internal резолвится в 10.8.0.1, поэтому ssh …:6722 упирается в гейтвей. Значит, гейтвей должен пробросить этот порт на Forgejo. Простейший способ — DNAT на гейтвее:
# TCP 6722 на гейтвее → SSH-порт Forgejo в приватной сети
iptables -t nat -A PREROUTING -i wg0 -p tcp --dport 6722 -j DNAT --to-destination 10.0.0.12:6722
iptables -A FORWARD -p tcp -d 10.0.0.12 --dport 6722 -j ACCEPT
SSH_DOMAIN=git.team.internal и SSH_PORT=6722 в конфиге Forgejo при этом не трогаем — clone-строка у команды остается прежней. Любители держать все в одном конфиге могут вместо iptables взять у Caddy модуль layer4 и проксировать TCP там же, но для одного порта DNAT проще и прозрачнее.
Раннер и внутренний CA
Раннер на своей ВМ ходит в Forgejo по приватному адресу plain HTTP — http://10.0.0.12:3000, минуя Caddy и CA ровно так же, как в одномашинной схеме он ходил на 10.8.0.1:3000. При регистрации указываем этот адрес как Instance URL. То есть грабли внутреннего CA для CI решаются тем же приемом — раннер просто не задевает HTTPS, и неважно, на той же машине Forgejo или на соседней.
Исходящий трафик и файрвол
У бэкендов нет публичного IP, но наружу им ходить надо — за apt и docker pull. Выход дает либо NAT на гейтвее (MASQUERADE для приватной подсети, он у нас и так включен ради WireGuard), либо облачный NAT-шлюз. Группы безопасности ужимаем по ролям: у гейтвея наружу открыты только WireGuard и админский SSH. Бэкенды публичных правил не имеют вовсе и принимают трафик только из приватной подсети — Forgejo на 3000 (Caddy и раннер) и 22 (DNAT с гейтвея), Matrix на 8008/8080 (Caddy).
Чем платим
Гейтвей становится единой точкой входа — а значит, и единой точкой отказа, и узким местом для всего HTTP-трафика контура. Для нашего масштаба это приемлемо: гейтвей легкий (маршрутизация, DNS, реверс-прокси — почти не нагружают CPU), а взамен мы получаем простое управление — один вход, один CA, один набор правил наружу.
Но если чокпоинт на гейтвее нежелателен, есть альтернатива: пустить приватную подсеть в туннель (AllowedIPs += 10.0.0.0/24), резолвить имена сразу в адреса бэкендов и поставить Caddy на каждую ВМ. Это убирает лишний хоп, но размазывает управление CA по машинам — для компактной команды централизованный гейтвей обычно выгоднее.
Главное, что во всех вариантах не меняется суть: сервисы не смотрят в интернет, единственная дверь снаружи — WireGuard, а HTTPS внутри контура держит собственный CA.
Заключение

На одной маленькой виртуалке Evolution Compute — без Kubernetes, без DevOps-комбайна и без вывода единого порта в публичный интернет — мы получили полноценный корпоративный Git-сервис: репозитории, pull request с code review, автоматическую валидацию и тесты на каждый PR и защищенную ветку main.
Весь стек — это два compose-файла на полсотни строк, которые целиком приведены в статье и воспроизводятся за вечер.
Value proposition для небольшой команды складывается из трех вещей.
Контроль: код и вся история разработки физически лежат на вашей инфраструктуре.
Независимость: никакой внешний SaaS не изменит вам условия и не закроет доступ.
Цена входа: легкость Forgejo позволяет стартовать на конфигурации уровня free tier и платить за облако сотни рублей в месяц, а не тысячи — апгрейд по мере роста делается без переезда.
Что дальше?
Напрашивается тема связки сервисов в единый рабочий контур: бот в Matrix, который не только присылает уведомления о PR, но и умеет отвечать статусами пайплайнов. А также зеркалирование критичных внешних зависимостей и полноценный релизный процесс с registry.
Может быть, это потянет на новую часть цикла «довели»… Хотя, честно говоря, в этот раз меня никто не доводил, просто с прошлого раза понравился процесс. Советы, рекомендации и критику все так же принимаю в комменты, спасибо за внимание :)
Автор: restoniich

