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.shinstalldependencies.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 statussvc.sh создаёт юнит actions.runner.<владелец>-<репозиторий>.<имя>.service с запуском от runner. Подробные логи — в _diag/Runner_*.log и _diag/Worker_*.log.
- Переменные окружения раннер читает из
.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.shconcurrency: 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Контейнерные задания и сервисы работают только на 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/nulldocker 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.0needrestart на 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 ТОКЕН, токену нужен scopeworkflow. - Раннер молчал 14 дней, и GitHub его удалил — зарегистрируйте заново.
Runner version … is deprecated and cannot receive messages.
- Сам он уже не обновится: распакуйте актуальный архив в новый каталог, зарегистрируйте с тем же именем и
--replace, старую службу удалите.
No space left on device.
- Что выросло, покажут
df -h,docker system dfиdu -sh /home/runner/actions-runner/_work/* | sort -h. Уборка — в шаге 7, поиск виновника — в статье «Закончилось место на диске».
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.
Источники
- GitHub Docs: self-hosted runners и справочник по ним
- GitHub Docs: безопасность self-hosted runners и pull_request_target
- GitHub Docs: хуки до и после задания, лимиты, хостед-раннеры
- GitHub REST API для раннеров
- actions/runner 2.337.0, issues #434, #827, #2708
- GitHub Changelog: минимальная версия раннеров и перенос на 29 сентября
- GitHub Changelog: цены Actions
- GitHub Changelog: pull_request_target и actions/checkout
- GitLab Runner: установка, регистрация, Docker executor, config.toml, новая схема токенов
- StepSecurity: tj-actions/changed-files, Datadog: Shai-Hulud 2.0, StepSecurity: ChainDrop
- Хабр: несколько раннеров GitHub Actions на одном хосте и ошибки настройки GitLab Runner
- Docker: rootless mode, Next.js: память при сборке