Self-hosted runner GitHub Actions и GitLab Runner на VPS

Свой раннер GitHub Actions на VPS: установка self-hosted runner как службы, метки, ephemeral и JIT, Docker, очистка диска, безопасность, GitLab Runner и частые сбои.

Актуально на сентябрь 2026 года: actions/runner 2.337.0, GitLab Runner 19.4. С 29 сентября GitHub не регистрирует раннеры версий ниже 2.329.0 и не отдаёт задания тем, кто не поставил новый релиз за 30 дней, — об обновлениях в шаге 7.

Зачем свой раннер и когда он не нужен

Раннер — агент, который забирает задания у GitHub и выполняет их на вашей машине. Он сам подключается к GitHub по HTTPS, входящие порты ему не нужны. Обзор задач разработчика на VPS — «VPS для разработчика».

Что даёт свой раннер:

  • Кэши между запусками. Кэш npm, модули Go, слои Docker и тулчейны setup-* остаются на диске. Хостед-раннер стартует с чистой ВМ и тянет кэш через actions/cache, где бесплатно 10 ГБ на репозиторий.
  • Ресурсы. NVMe и нужный объём памяти, а задание может идти до 5 суток против 6 часов на хостед-раннере.
  • Минуты. Self-hosted не тратит минуты тарифа GitHub, которых в приватных репозиториях от 2000 в месяц. В декабре 2025 года GitHub объявил плату $0,002 за минуту self-hosted с 1 марта 2026-го, но после волны критики отложил её для пересмотра; на конец сентября 2026 года платы нет.
  • Трафик. Сборка в основном скачивает: docker pull, npm ci, pip install, apt, git clone. В ForestsNet Cloud входящий трафик не считается, в пакет идёт только исходящий: docker push, выгрузка артефактов и кэша на GitHub, деплой.
  • Постоянный IP. SSH боевых серверов можно открыть только для адреса раннера — см. «Деплой из Git».

Когда он не нужен:

  • Публичный репозиторий. Пул-реквест из форка может выполнить чужой код на вашем сервере, а хостед-раннеры для открытых проектов бесплатны.
  • Нужна чистая среда. Постоянный раннер помнит всё, что оставили предыдущие задания.
  • Редкие сборки. Если хватает бесплатных минут, обслуживание своего раннера не окупится.
  • Сборки под iOS. Им нужен Mac, Linux-сервер его не заменит. Эмулятор Android на наших VPS запустится — вложенная виртуализация включена, — но работает медленнее, чем на выделенном сервере.

Что понадобится

  • Отдельный VPS ForestsNet Cloud под CI на Ubuntu 22.04/24.04 или Debian 12, размер — в разделе «Сколько ресурсов нужно». На боевой сервер раннер не ставьте.
  • SSH-доступ с sudo — «Консоль VNC и первое подключение».
  • Права администратора репозитория или владельца организации.
  • Docker из официального репозитория, если задания собирают образы или используют container: и services: — «Docker и Docker Compose».

Шаг 1. Подготовьте сервер

Заведите пользователя без sudo и закройте все входящие порты, кроме SSH:

sudo useradd -m -s /bin/bash runner
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw enable

Подробнее о файрволе — в статье «Файрвол ufw: порты».

Шаг 2. Скачайте и зарегистрируйте раннер

Откройте Settings → Actions → Runners → New self-hosted runner в репозитории или организации и выберите Linux x64. Страница покажет команды и токен регистрации, который живёт час.

Совет: версию берите со страницы настроек: релизы раскатываются постепенно.

Команды для 2.337.0, сумма SHA-256 — из заметок к релизу:

sudo -iu runner
mkdir actions-runner && cd actions-runner
curl -o actions-runner-linux-x64-2.337.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.337.0/actions-runner-linux-x64-2.337.0.tar.gz
echo "70920811a4f8ad4328818682bca5c6469c1c942fab52448868071d0063816613  actions-runner-linux-x64-2.337.0.tar.gz" | sha256sum -c
tar xzf ./actions-runner-linux-x64-2.337.0.tar.gz
exit
sudo /home/runner/actions-runner/bin/installdependencies.sh

installdependencies.sh ставит системные библиотеки для .NET, ему нужен root. Регистрируют раннер снова от runner: от root config.sh ответит Must not run with sudo.

sudo -iu runner
cd ~/actions-runner
./config.sh --url https://github.com/OWNER/REPO --token ТОКЕН --name vps-ci-1 --labels vps,nvme --unattended

Полезные ключи config.sh:

  • --labels — свои метки к стандартным self-hosted, Linux, X64. Потом их меняют только в интерфейсе или через API.
  • --replace — заменить раннер с тем же именем. Свои метки после этого назначьте заново.
  • --ephemeral и --disableupdate — см. шаги 5 и 7.

Шаг 3. Запустите раннер как службу

Вместо ./run.sh в открытой сессии установите службу systemd:

cd /home/runner/actions-runner
sudo ./svc.sh install runner
sudo ./svc.sh start
sudo ./svc.sh status

svc.sh создаёт юнит actions.runner.<владелец>-<репозиторий>.<имя>.service с запуском от runner. Подробные логи — в _diag/Runner_*.log и _diag/Worker_*.log.

Готово: в journalctl -u 'actions.runner.*' -f появилось Listening for Jobs, а в настройках раннер в статусе Idle.
  • Переменные окружения раннер читает из .env в своём каталоге: строки КЛЮЧ=значение без кавычек. После правки перезапустите службу: sudo ./svc.sh stop && sudo ./svc.sh start.
  • PATH службы записан в файл .path при регистрации. Поставили Go или Node.js позже — допишите путь туда и перезапустите службу.

Снять раннер с регистрации можно только после удаления службы, иначе config.sh ответит Uninstall service first. Токен удаления — на странице раннера.

cd /home/runner/actions-runner
sudo ./svc.sh uninstall
sudo -u runner ./config.sh remove --token ТОКЕН_УДАЛЕНИЯ

Шаг 4. Метки, runs-on и параллельные задания

Задание уходит на раннер, у которого есть все метки из runs-on, self-hosted пишут первой. Раннеры организации можно выбирать по группе:

jobs:
  build:
    runs-on: [self-hosted, linux, x64, vps]
    steps:
      - uses: actions/checkout@v7
      - run: npm ci && npm run build

  deploy:
    needs: build
    runs-on:
      group: ci-vps
      labels: [linux]
    concurrency: production
    steps:
      - uses: actions/checkout@v7
      - run: ./deploy.sh

concurrency: production не пустит две выкладки одновременно. Без подходящего раннера задание ждёт в очереди и через 24 часа отменяется.

Один раннер выполняет одно задание за раз. Для параллельной работы распакуйте архив во второй каталог и зарегистрируйте под другим именем со своей службой — копия настроенного каталога упрётся в A session for this runner already exists. _work у раннеров свой, а Docker, домашний каталог и порты общие:

  • тесты на фиксированном порту во втором задании падают с port is already allocated;
  • docker compose берёт имя проекта из имени каталога, у двух копий репозитория оно одинаковое, и задания гасят чужие контейнеры. Задайте COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }}.

Шаг 5. Ephemeral и JIT: чистая среда на каждое задание

Постоянный раннер копит состояние: файлы, контейнеры, забытые процессы. Скомпрометированное задание заражает машину для всех следующих.

--ephemeral даёт раннер на одно задание, но диск не чистит, поэтому смысл есть, только если вместе с раннером пересоздаётся среда. JIT-раннер (just-in-time) — такой же одноразовый, но без токена регистрации: API выдаёт готовую конфигурацию для ./run.sh --jitconfig. Минимальная схема — цикл, который на каждое задание запускает новый контейнер из официального образа:

#!/usr/bin/env bash
# /usr/local/bin/ci-jit-loop.sh — один контейнер на одно задание
set -euo pipefail
REPO="OWNER/REPO"
while true; do
  JIT=$(gh api -X POST "repos/$REPO/actions/runners/generate-jitconfig" \
    -f name="jit-$(date +%s)" -F runner_group_id=1 \
    -f 'labels[]=self-hosted' -f 'labels[]=linux' -f 'labels[]=jit' \
    --jq .encoded_jit_config)
  docker run --rm --pull always ghcr.io/actions/actions-runner:latest ./run.sh --jitconfig "$JIT"
done

Цикл запускайте службой systemd — логи раннеров останутся в journald. Для gh нужен токен в GH_TOKEN: классический со scope repo или fine-grained с правом Administration на запись. Токен остаётся на хосте, в контейнер уходит только одноразовая конфигурация.

Демона Docker в контейнере нет: container:, services: и сборка образов здесь не работают, а проброс /var/run/docker.sock вернёт заданию root на хосте.

Шаг 6. Docker в заданиях

Добавьте пользователя раннера в группу docker и перезапустите службу:

sudo usermod -aG docker runner
cd /home/runner/actions-runner && sudo ./svc.sh stop && sudo ./svc.sh start
Важно: группа docker равносильна root: задание может выполнить docker run -v /:/host и получить весь сервер. Такой раннер — только на отдельном сервере и для доверенных репозиториев.

Контейнерные задания и сервисы работают только на Linux с Docker. В контейнер задания раннер монтирует весь _work и /var/run/docker.sock хоста, так что container: от сервера не изолирует.

jobs:
  test:
    runs-on: [self-hosted, linux]
    container: node:24-bookworm
    services:
      postgres:
        image: postgres:18
        env:
          POSTGRES_PASSWORD: postgres
    steps:
      - uses: actions/checkout@v7
      - run: npm ci && npm test
        env:
          DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres
  • Файлы root в рабочем каталоге. В контейнере задание по умолчанию работает от root, и следующий actions/checkout падает на его файлах с EACCES: permission denied (issue #434, открыт с 2020 года). Помогает хук из шага 7 или запуск контейнера от UID раннера через options: --user, если образ позволяет.
  • Порты мимо ufw. Опубликованные порты Docker открывает своими правилами iptables, в обход ufw. Из контейнерного задания ходите к сервису по имени без ports:, а на самом раннере публикуйте порт только на localhost: ports: ["127.0.0.1:5432:5432"].
  • Кэш сборки образов. docker/setup-buildx-action по умолчанию удаляет свой builder вместе с кэшем в конце задания — задайте driver: docker или постоянное name с keep-state: true. Кэш type=gha на своём раннере лишний.
  • Rootless Docker ограничивает ущерб правами пользователя runner, сокет задают в .env: DOCKER_HOST=unix:///run/user/<UID>/docker.sock. Но внутри контейнерных заданий Docker так недоступен: раннер жёстко монтирует /var/run/docker.sock (issue #827).

Шаг 7. Обслуживание: диск, кэши, обновления

Что занимает диск

  • _work — рабочие копии, тулчейны и скачанные actions, раннер их не чистит;
  • bin.* и externals.* прошлых версий после автообновлений (issue #2708);
  • образы, тома и кэш сборки Docker — обычно больше всего;
  • ~/.npm, ~/.cache/go-build, ~/go/pkg/mod пользователя runner.

Логи в _diag раннер сам хранит 30 дней.

Уборка

# /etc/cron.d/ci-cleanup
0 4 * * * root docker system prune -af --filter "until=72h" > /dev/null
10 4 * * * root docker builder prune -af --filter "until=72h" > /dev/null
20 4 * * * root docker volume prune -f > /dev/null

docker volume prune без -a удаляет только анонимные тома — их оставляют сервисы вроде postgres.

Если диск заполняется за день, добавьте хук: скрипт из ACTIONS_RUNNER_HOOK_JOB_COMPLETED выполняется после каждого задания, из ACTIONS_RUNNER_HOOK_JOB_STARTED — перед ним. Скрипт кладут вне каталога раннера, с правом на исполнение и расширением .sh; ненулевой код выхода роняет задание. Пример чинит владельца файлов и убирает остановленные контейнеры:

# строка в /home/runner/actions-runner/.env
ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/home/runner/hooks/job-completed.sh
#!/usr/bin/env bash
# /home/runner/hooks/job-completed.sh, права 755
timeout 120 docker run --rm -v "$GITHUB_WORKSPACE":/w alpine chown -R "$(id -u):$(id -g)" /w || true
docker container prune -f > /dev/null || true

Обновления

Автообновление включено по умолчанию: раннер обновляется, получив задание, а без заданий — в течение недели после релиза.

С 29 сентября 2026 года раннер, не поставивший новый релиз за 30 дней, не получает заданий, а после критического обновления безопасности — до его установки. Устаревший раннер получает ответ Runner version v2.xxx.x is deprecated and cannot receive messages и останавливается вместе со службой. --disableupdate — только если вы сами обновляете раннер ежемесячно. Срок для своей версии покажет API в поле runtime_deprecates_at:

gh api repos/OWNER/REPO/actions/runners/deprecations/2.336.0

needrestart на Ubuntu и Debian может перезапустить раннер посреди задания — GitHub советует исключить службу раннера:

echo '$nrconf{override_rc}{qr(^actions\.runner\..+\.service$)} = 0;' | sudo tee /etc/needrestart/conf.d/actions_runner_services.conf

Шаг 8. Безопасность

  • Только приватные репозитории. Так советует GitHub: пул-реквест из форка публичного репозитория выполнит на вашей машине любой код. В приватном репозитории это сможет любой с правом чтения, если разрешён запуск workflow из форков.
  • Если публичный неизбежен. Включите Require approval for all external contributors, отдавайте раннеру только push в защищённые ветки, а PR из форков — хостед-раннерам. Код форка в pull_request_target не запускайте: там секреты базового репозитория. С лета 2026 года actions/checkout сам отказывается забирать такой код, а со 2 ноября политика по умолчанию заблокирует это событие в публичных репозиториях.
  • Группы раннеров открывайте только нужным репозиториям. Публичным доступ по умолчанию закрыт — так и оставьте.
  • Секреты. Любое задание читает то же, что пользователь раннера: ~/.ssh, облачные токены, чужие рабочие копии в _work, ~/.docker/config.json. Для docker login задавайте DOCKER_CONFIG: ${{ runner.temp }}/docker. Environments с подтверждением на постоянном раннере не спасают: прошлое задание может оставить процесс, который перехватит секреты. Права GITHUB_TOKEN сужайте через permissions:.
  • Без sudo. Червь ChainDrop в августе 2026 года запускал на раннерах sudo python3 и вычитывал секреты из памяти процесса Runner.Worker.
  • Цепочка поставок. Закрепляйте actions по SHA коммита: в марте 2025 года теги tj-actions/changed-files перевесили на коммит, печатавший секреты из памяти раннера в лог. Червь Shai-Hulud 2.0 в ноябре 2025 года ставил на заражённые машины раннер SHA1HULUD с управлением через Discussions — сверяйте список раннеров. npm-пакеты по возможности ставьте с --ignore-scripts.
  • Отдельный сервер. Раннер с Docker на боевом сервере даёт root любому, кто может запушить в репозиторий. CI-сервер защищайте по статьям «Усиление SSH» и «Чеклист базовой защиты VPS».

GitLab Runner: установка и регистрация

Схема та же: агент сам опрашивает GitLab раз в 3 секунды, входящие порты не нужны. Для сборок берите executor docker: каждое задание идёт в новом контейнере из образа, заданного в .gitlab-ci.yml. Для своего Git-сервера есть Forgejo Actions — «Свой Git-сервер на VPS».

Установка

curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" -o script.deb.sh
less script.deb.sh
sudo bash script.deb.sh
sudo apt install gitlab-runner

Пакет создаёт пользователя gitlab-runner и службу, настройки — в /etc/gitlab-runner/config.toml. Служба работает от root, поэтому для docker executor добавлять gitlab-runner в группу docker не нужно.

Установка и обновление пакета запускают clear-docker-cache prune, в 19.2.0–19.2.2 он по ошибке удалял все неиспользуемые образы. Без уборки: sudo NO_DOCKER_PRUNE=1 apt install gitlab-runner.

Регистрация по токену glrt-

Раннер создают в интерфейсе: Settings → CI/CD → Runners → Create project runner, нужна роль Maintainer. Там же задают теги, Run untagged, защищённость и тайм-аут, а на выходе получают токен glrt-, который показывают один раз:

sudo gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.com/" \
  --token "glrt-ВАШ_ТОКЕН" \
  --executor "docker" \
  --docker-image alpine:latest \
  --docker-pull-policy "if-not-present" \
  --description "vps-docker"
sudo gitlab-runner verify

Старые регистрационные токены (--registration-token) уберут в GitLab 20.0. Ответ 410 Gone - runner registration disallowed значит, что они уже выключены.

config.toml: параллельность и Docker

concurrent = 2

[[runners]]
  name = "vps-docker"
  executor = "docker"
  limit = 2
  [runners.docker]
    image = "alpine:latest"
    privileged = false
    pull_policy = ["if-not-present"]
    allowed_images = ["alpine:*", "node:*", "golang:*"]
  • concurrent — предел заданий на весь файл. После регистрации там 1, и задания идут строго по одному.
  • limit — предел одного раннера, 0 — без ограничения.
  • pull_policy по умолчанию always; if-not-present быстрее, но latest тогда обновляйте сами.
  • allowed_images — белый список образов для заданий.

Файл перечитывается каждые 3 секунды, без перезапуска.

privileged = true для Docker-in-Docker, как и проброс /var/run/docker.sock, открывает заданию путь к root на хосте. Безопаснее rootless DinD (services_privileged и allowed_privileged_services), BuildKit rootless или Buildah; Kaniko больше не поддерживается.

Уборка и безопасность

  • /usr/share/gitlab-runner/clear-docker-cache удаляет неиспользуемые контейнеры и тома раннера. Запускайте его по cron раз в неделю ночью — во время задания он может удалить его контейнеры. Собранные в заданиях образы он не трогает, их уберёт docker system prune.
  • Отметьте раннер Protected, чтобы он брал задания только из защищённых веток и тегов.
  • Пайплайн merge request из форка по умолчанию идёт в проекте форка. Запуская его у себя, вы отдаёте коду форка свой раннер и переменные — только после ревью.

Сколько ресурсов нужно

На один раннер:

  • Линтеры, юнит-тесты, выкладка по SSH — 1–2 vCPU, 2 ГБ памяти плюс своп, 20–30 ГБ диска.
  • Фронтенд: Next.js, Vite, webpack — 2 vCPU и 4 ГБ, крупным проектам — со свопом, иначе JavaScript heap out of memory или код 137 от OOM killer. Для машины с 2 ГБ документация Node.js советует --max-old-space-size=1536.
  • Go — сборка параллелится по ядрам: 4 vCPU заметно быстрее двух, памяти обычно хватает 2–4 ГБ.
  • Docker-образы и тесты с PostgreSQL или Redis — 4 vCPU, 8 ГБ и 60–80 ГБ диска под образы и кэш сборки.
  • Каждый дополнительный параллельный раннер — ещё 2 vCPU и 2–4 ГБ.

Для сравнения: хостед-раннер GitHub — 2 vCPU и 8 ГБ в приватных репозиториях, 4 vCPU и 16 ГБ в публичных, диск 14 ГБ.

sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

Узкие места ищите по статье «Высокая нагрузка на сервер», а монорепозиториям и тяжёлым тестам на эмуляторах удобнее выделенный сервер.

Диагностика

Раннер в статусе Offline.

  • Служба остановлена: sudo ./svc.sh status, journalctl -u 'actions.runner.*' -n 100, общий порядок — «Сервис не запускается».
  • Раннер запускали через ./run.sh, и он завершился вместе с SSH-сессией. Поставьте службу.
  • Не прошло самообновление: каталог принадлежит root или кончилось место. Смотрите _diag/SelfUpdate-*.log и верните владельца: sudo chown -R runner:runner /home/runner/actions-runner.
  • Нет связи с GitHub: проверьте ./config.sh --check --url https://github.com/OWNER/REPO --pat ТОКЕН, токену нужен scope workflow.
  • Раннер молчал 14 дней, и GitHub его удалил — зарегистрируйте заново.

Runner version … is deprecated and cannot receive messages.

  • Сам он уже не обновится: распакуйте актуальный архив в новый каталог, зарегистрируйте с тем же именем и --replace, старую службу удалите.

No space left on device.

Permission denied на /var/run/docker.sock.

  • Пользователь раннера не в группе docker, или службу не перезапустили после добавления — см. шаг 6.

Задание висит на «Waiting for a runner to pick up this job».

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

checkout падает с EACCES.

  • В _work остались файлы root от контейнерного задания. Разово: sudo chown -R runner:runner /home/runner/actions-runner/_work, насовсем — хук из шага 7.

GitLab: «This job is stuck».

  • Нет онлайн-раннера с тегами задания, выключен Run untagged, или раннер Protected, а ветка нет.
  • concurrent = 1, и раннер занят. Регистрацию проверит sudo gitlab-runner verify.

Источники