Github - отличная площадка, подарившая удаленные репозитории всем и каждому, стала центром опенсорса, упростила cicd создав удобные actions, изобрели pr без которых не обходится современная разработка. Я не собираюсь переезжать и не предлагаю это всем. Но исходники, pr, cicd и привычный git push команды зависят от сервиса со своим проблемами, над которым нет ни операционного, ни сетевого (в последнее время) контроля

А теперь к актуальному.

  • Апрель 2026 - баг merge queue откатил изменения в 2092 пул реквестах и исправили это недопустимым force-push. Разбор.

  • Декабрь 2025 - gh объявил о намерение брать деньги за self-hosted раннеры. Пользователь покупает и обслуживает сервер, и платит gh за работу своих раннеров.

  • За последние 2 года сервис штормит, githubstatus.com не бывает зеленым даже неделю. Хотя инженеры комании справедливо связывают проблемы с огромным наплывом комитов и реквестов из-за ai бума.

Поэтому сегодня поднимем свой git сервис на небольшой vps, подключим actions – и самое важное – сделаем бекап и проведем полное восстановление системы. Пройдемся по важным местам в безопасности, по разному создадим репозитории, настроим runner.

Ниже – весь путь. Статья рассчитана на разработчика или небольшую команду, которым нужен понятный git хостинг, а не комбайн с отдельным человеком для обслуживания.

Что получится

В результате будет простой, но взрослый контур работы с репозиториями. Не github на минималках, а полноценная работа с репозиториями, issues, pr, организациями, командами, ssh, actions, пакетами, api для доп автоматизации и полными бекапами. При этом команда контролирует данные, знает как восстановить сервис и может оставить github зеркалом проекта а не единственной точкой отказа.

Принципиальная схема своего git контура
Принципиальная схема своего git контура

Почему gitea, а не gitlab

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

Gitea - компактный go бинарник с привычным интерфейсом git хостинга. Ради эксперимента я поднимал сервис на 0.25 vcpu и 512 mb памяти: несколько репозиторий и ежедневные push работали без проблем. Для статьи развернул всё на нормальной машинке.

VPS брал как обычно на petrosky.io, 1cpu и 2 gb ram за 7 европейских рубля.

Локаций сейчас две - Канада и Франция, для этого сервера взял Францию. Отдельно пригодилось то, что при создании VPS можно выбрать Gitea из списка готовых приложений. То есть вместо установки с нуля я получил стабильную автоматическую развертку приложения, после чего осталось только заняться самым приятным - настроить всё, чем мы сегодня и займемся.

По цене выходит дешевле многих российских провайдеров, плюс давно уже спокойно оплачиваю там криптой. Для git-сервера ресурсов хватает с запасом на несколько моих с командой проектов.

Перед стартом

  1. Домен. Я добавил поддомен git.ivan-noskov.ru с А записью на ip сервера.

  2. Где будет runner. В рамках статьи раннер развернул у себя на маке - для своих проектов эта небезопасная схема мне очень нравится - cicd летает на бешеных скоростях.

  3. Резервная копия. Архивы резервных копий нужно хранить на независимой площадке: другая машина, объектное хранилище или на диске.

Установка gitea

В моем случае gitea установился по скрипту провайдера - он был в маркетплейсе приложений. Получился gitea 1.27.3 как systemd-сервис, mariadb и caddy для https.

Установка gitea на vps
Установка gitea на vps

Моя схема:

Gitea binary:   /opt/gitea/gitea
Gitea config:   /opt/gitea/custom/conf/app.ini
Repositories:  /opt/gitea/repos
Data:              /opt/gitea/data
Logs:              /opt/gitea/log
Systemd:        /etc/systemd/system/gitea.service
Reverse proxy: Caddy, /opt/caddy/caddyfile
Database:       MariaDB/MySQL
SSH for Git:    port 2222, user gitea

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

Самому gitea можно поставить через докер или бинарём.

Пользователи, орги и права: не выдаем всем админа

У gitea есть два уровня, которые лучше не смешивать:

  • пользователь - личная учетка человека, его ключи и персональные репозитории;

  • организация - общий namespace команды, например back или product. Доступом внутри управляют команды.

Пользователей я создал в админке: 

Создание пользователя
Создание пользователя

Организацию создаем через + > Создать организацию. Затем добавляем в неё команды, например dev с write доступом и readers только с чтением. Так не придется выдавать права на каждый новый проект. Вообще система прав в gitea отдельно меня порадовала.

Настройка прав
Настройка прав

Мой минимум безопасности:

  • админ - отдельная учетка, а не ежедневный рабочий акк;

  • разрабы - права через команду организации;

  • двухфакторка обязательная для владельцев и админа;

  • регистрация выключена, доступ по vpn только для команды;

  • обновлять gitea и os.

Последний пункт не декоративный. Свой gitea - ещё одна поверхность атаки. https://habr.com/ru/articles/1072030 - разбор компроментации плохо защищенного сервера с установкой майнера.

Настройка двухфакторки тут, если что:

Двухфакторка
Двухфакторка

SSH: ключ добавлен, но Permission denied - ещё не диагноз

Добавим работу по ssh с репами. В профиле пользователя добавляем свой ключ Настройки > ssh/grg keys.

Добавление ssh
Добавление ssh

Gitea просит отдельно подтвердить владение ключем. Интерфейс предложит подписать одноразовый токен через ssh-keygen -y sign:

echo -n ‘{ваш токен}' \
 | ssh-keygen -Y sign -n gitea -f ~/.ssh/{ваш ключик}

Вставляем то что получили и gitea теперь нам верит.

Ключ SSH «SHA256:Yw***********************j67qGzNyE» верифицирован.

Самое интересное: создаем репозиторий, мигрируем с github

Есть три сценария. Они не конкурируют, а решают разные задачи.

1. Новый проект сразу в gitea

+ > Новый репозиторий. После создания получаем готовую к работе репу.

mkdir project && cd project
git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin ssh://gitea@{ваш домен}:2222/{ваша орга}/{ваш репо}.git
git push -u origin main
Интерфейс репозитория в gitea
Интерфейс репозитория в gitea

2. Импорт из github

Жмем + > Новая миграция, среди разнообразия выбираем наш github и заполняем форму:

Импорт репозитория из github
Импорт репозитория из github

Я буду работать со своим проектом тетриса на двоих. Нагенерил как-то простую игру, чтобы можно было на одной клаве играть с девушкой: тут можно поиграть.

Мой tetris на двоих
Мой tetris на двоих

Для публичного репозитория достаточно ссылки, для приватного нужно будет вставить токен с gh. Перед запуском импорта, прочитайте список импортируемых сущностей. Git-история, issues, pull request'ы, вики, labels, releases и LFS зависят от источника и разрешений токена.

После переноса проверьте:

- ветки и теги;
- историю коммитов и авторов;
- LFS и большие релизные файлы;
- issues и PR, если переносили их;
- webhooks, deploy keys, Actions secrets и интеграции - не считайте их автоматически перенесёнными;
- default branch и права доступа.

Перенесенный репозиторий
Перенесенный репозиторий

3. Локальная копия уже есть: добавляем Gitea

Это мой любимый безопасный переход. Старый origin не удаляем, а переименовываем в github. Новую Gitea делаем origin:

cd ~/projects/tetris
git remote rename origin github
git remote add origin ssh://gitea@{ваш домен}:2222/acme/tetris.git
git remote -v
git fetch origin
git push -u origin main

Теперь обычный git push отправляет код в Gitea, а GitHub остаётся известным remote для сверки или временного зеркала:

Смена origin
Смена origin

Это и есть спокойная миграция: публичный GitHub может остаться основой или зеркалом, но работоспособность команды больше не зависит от него как от единственного remote.

CI: подключаем Gitea Actions

Очень классная фишка проудукта - Gitea Actions совместимы с синтаксисом GitHub Actions. Сначала создадим runner. В панели Gitea получите registration token: Настройки > Действия > Runners.

Для первой проверки я использовал Mac с Docker Desktop. Данные runner'а и токен храню отдельно, файл токена доступен только владельцу:

RUNNER_HOME="$HOME/gitea-runner"
mkdir -p "$RUNNER_HOME/data"
chmod 700 "$RUNNER_HOME"
read -s 'RUNNER_TOKEN?Вставьте токен runner: '
printf '\n'
printf '%s' "$RUNNER_TOKEN" > "$RUNNER_HOME/registration-token"
unset RUNNER_TOKEN
chmod 600 "$RUNNER_HOME/registration-token"
docker pull docker.io/gitea/runner:3

docker run -d \
  --name gitea-runner \
  --restart unless-stopped \
  -v "$RUNNER_HOME/data:/data" \
  -v "$RUNNER_HOME/registration-token:/run/secrets/registration-token:ro" \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -e GITEA_INSTANCE_URL=https://git.example.com/ \
  -e GITEA_RUNNER_REGISTRATION_TOKEN_FILE=/run/secrets/registration-token \
  -e GITEA_RUNNER_NAME=runner-01 \
  -e GITEA_RUNNER_LABELS=node20:docker://node:20-bookworm \
  docker.io/gitea/runner:3

docker logs --tail 100 gitea-runner

У меня прошло без проблем:

Запуск раннера
Запуск раннера

У этой схемы есть важное ограничение. Монтирование /var/run/docker.sock даёт job'ам доступ к Docker-демону хоста. Такой runner допустим только для доверенных репозиториев и людей. Не подключайте его к публичным форкам и не считайте локальный runner production-исполнителем. Официальная документация Gitea разбирает этот компромисс и варианты изоляции в разделе https://docs.gitea.com/runner/installation/docker

Первый workflow

В проект tetris я добавил .gitea/workflows/verify.yml:

name: Verify

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  verify:
    runs-on: node20
    steps:
      - name: Check out repository
        uses: actions/checkout@v4
      - name: Install dependencies
        run: npm ci
      - name: Lint
        run: npm run lint
      - name: Build
        run: npm run build
Результат выполнения actions
Результат выполнения actions

Первый запуск оказался красным, я не стал подгонять статью под зелёную галочку. Runner зарегистрировался, workflow запустился, зависимости установились, а CI честно остановил сборку на реальных ошибках. После их исправления зелёный запуск можно сделать условием для merge.

Бэкап - не команда, а доказанное восстановление

Для согласованной копии Gitea нужно остановить: база, файлы и Git-репозитории меняются одновременно. Автоматической команды «полностью восстановить всё» тоже нет - это контролируемая ручная процедура. Подробности есть в официальной документации https://docs.gitea.com/administration/backup-and-restore

Я проверял свою схему: gitea + MariaDB + caddy. При другом способе установки изменятся пути и, возможно, команды, но состав копии останется тем же:

  1. SQL-дамп базы: пользователи, настройки, issues, PR, runner'ы, метаданные.

  2. Каталоги Gitea: repositories, data, custom/config.

  3. Unit systemd и конфигурация reverse proxy - чтобы поднять сервис с теми же точками входа.

  4. Контрольные суммы и независимая копия архива.

Создание резервной копии

Ниже - шаблон для моего варианта установки из бинарника. Запускать нужно от рута.

set -euo pipefail
BACKUP_ROOT=/root/gitea-backups
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
BACKUP_DIR="$BACKUP_ROOT/$STAMP"
APP_INI=/opt/gitea/custom/conf/app.ini
install -d -m 700 "$BACKUP_DIR"

# Выпишите DB_NAME/USER/PASS/HOST/PORT из APP_INI в закрытый временный defaults-файл.
# Не коммитьте этот файл и удалите его после выполнения.

MYSQL_DEFAULTS=$(mktemp)
chmod 600 "$MYSQL_DEFAULTS"
cat > "$MYSQL_DEFAULTS" <<'EOF'

[client]
user=GITEA_DB_USER
password=GITEA_DB_PASSWORD
host=127.0.0.1
port=3306

EOF

cleanup() {
  rm -f "$MYSQL_DEFAULTS"
  systemctl start gitea || true
}

trap cleanup EXIT
systemctl stop gitea

mysqldump --defaults-extra-file="$MYSQL_DEFAULTS" \
  --single-transaction --routines --events --triggers \
  --databases GITEA_DB_NAME | gzip -9 > "$BACKUP_DIR/database.sql.gz"

tar --xattrs --acls --numeric-owner -C / -czf "$BACKUP_DIR/files.tar.gz" \
  opt/gitea \
  opt/caddy \
  etc/systemd/system/gitea.service \
  etc/systemd/system/caddy.service

(cd "$BACKUP_DIR" && sha256sum database.sql.gz files.tar.gz > SHA256SUMS)
gzip -t "$BACKUP_DIR/database.sql.gz"
(cd "$BACKUP_DIR" && sha256sum -c SHA256SUMS)
tar -tzf "$BACKUP_DIR/files.tar.gz" >/dev/null
systemctl start gitea
trap - EXIT

rm -f "$MYSQL_DEFAULTS"

Бекап получился около 78мб: sql дамп, файловый архив и sha256sums.

Тут я получил маленький, но полезный урок. Сначала я записал контрольные суммы с абсолютными путями. На другом хосте такой манифест не проверить. Поэтому в шаблоне выше контрольные суммы создаются внутри каталога бекапа и содержит относительные имена файлов.

Бекап я поместил на ноут, в реальной эксплуатации так делать не нужно. Копия нужна на независимом носителе и с шифрованием.

Восстановление и тесты

Я сознательно тестировал на той же vps и был готов всё потерять. Не повторяйте с рабочим инстансом.

Последовательность такая:

  1. Остановил и снёс под чистую всё на vps

  2. Установил чистый gitea той же версии, тем же способом

  3. Распаковал custom, data, repos и unit из бекапа, восстановил sql дамп

  4. Запустил gitea, прошел чек-лист приёмки восстановления

set -euo pipefail
RESTORE_DIR=/root/gitea-restore-from-external/20260902T145457Z
(cd "$RESTORE_DIR" && sha256sum -c SHA256SUMS)
systemctl stop gitea

# До этого шага должны быть подтверждены RESTORE_DIR и независимая копия.
# Команды удаляют данные Gitea на текущей VPS.

systemctl disable gitea
rm -f /etc/systemd/system/gitea.service
rm -rf /opt/gitea
mysql -e 'DROP DATABASE IF EXISTS gitea'

# Устанавливаем проверенный бинарник той же версии, создаём пустую БД и пользователя.
# Конкретные DB_NAME/USER и GRANT должны совпасть с app.ini из архива.

tar --xattrs --acls --numeric-owner -xzf "$RESTORE_DIR/files.tar.gz" -C / \
  opt/gitea/custom \
  opt/gitea/data \
  opt/gitea/repos \
  etc/systemd/system/gitea.service

chown -R gitea:gitea /opt/gitea/custom /opt/gitea/data /opt/gitea/repos
gzip -dc "$RESTORE_DIR/database.sql.gz" | mysql
systemctl daemon-reload
systemctl enable --now gitea
su -s /bin/sh -c '/opt/gitea/gitea admin regenerate hooks' gitea

Важные оговорки:

  • восстанавливать нужно на ту же версию gitea. «Заодно обновлю всё до последнего» может стать большой ошибкой;

  • в моём архиве сохранён app.ini, поэтому параметры базы и SSH совпали с прежней установкой. На другом хосте осознанно замените ROOT_URL, DNS, адреса и секреты;

  • если меняется каталог бинарника или тип установки, перегенерируйте hooks. Иначе сайт может выглядеть живым, а git push начнёт падать;

Чек лист после восстановления

# Сервис и сеть

systemctl is-active gitea
systemctl is-active caddy
ss -ltn | grep ':2222'
curl -fsS -o /dev/null -w '%{http_code}\n' https://{ваш домен}/

# Данные Git и приложение

find /opt/gitea/repos -type d -name '*.git' | wc -l
mysql -N -e 'USE gitea; SELECT COUNT(*) FROM user; SELECT COUNT(*) FROM repository;'

# Git по сети: URL нужно взять из интерфейса Gitea

git ls-remote --heads ssh://gitea@{ваш домен}:2222/{ваша организация}/{ваша репа}.git main
ssh -i ~/.ssh/id_ed25519 -o IdentitiesOnly=yes -T -p 2222 gitea@{ваш домен}

После восстановления gitea и caddy были активны, https вернул 200, ssh отвечал и в базе остались все пользователи и репозитории.

Что я бы добавил перед тем, как звать команду

  • Настроил регулярный бекап по таймеру и оповещение о неуспешном запуске.

  • Шифровал архив до отправки во внешнее хранилище.

  • Раз в квартал проводил restore drill на отдельной VPS и фиксировал фактические RTO/RPO.

  • Ограничил firewall: 22 для администрирования, 80/443 для веба, 2222 - только если нужен встроенный SSH Gitea.

  • Поднял отдельную vm под runner.

  • Включил защиту main.

  • Оставил GitHub зеркалом.

Итог

Один небольшой VPS дал нам независимый маршрут разработки: пользователи и права в организациях, Git по SSH, миграцию из GitHub без резкого обрыва, CI через Actions и проверенное восстановление.

Но самый ценный результат - не сама Gitea. Команда теперь знает, где лежат данные, как работает push, кто запускает CI, где хранится копия и как вернуть сервис после потери сервера. Когда эти ответы проверены руками, внешний Git-хостинг перестаёт быть единственной опорой и остаётся удобным инструментом, который можно использовать на своих условиях.