GitHub Actions self-hosted runner setup on a VPS

GitHub Actions self-hosted runner setup on a VPS: run it as a service, labels, ephemeral and JIT runners, Docker jobs, disk cleanup, security and GitLab Runner.

Current as of September 2026: actions/runner 2.337.0, GitLab Runner 19.4. Since September 29, GitHub won't register runners below 2.329.0 and stops assigning jobs to runners that haven't installed a new release within 30 days — see step 7.

Why run your own runner, and when not to

A runner is an agent that picks up jobs from GitHub and runs them on your machine. It opens the HTTPS connection to GitHub itself, so it needs no inbound ports. For other developer workloads on a server, see "VPS for developers".

What you get:

  • Warm caches. The npm cache, Go modules, Docker layers and setup-* toolchains stay on disk. A hosted runner starts from a clean VM and pulls its cache through actions/cache, free up to 10 GB per repository.
  • Resources. NVMe and as much memory as the build needs; a job can run up to 5 days instead of 6 hours.
  • Minutes. Self-hosted jobs don't consume your GitHub plan's included minutes (at least 2,000 a month for private repositories). In December 2025 GitHub announced a $0.002 per-minute charge for self-hosted runners starting March 1, 2026, then postponed it after the backlash to re-evaluate. As of late September 2026 there is no such charge.
  • Traffic. Builds mostly download: docker pull, npm ci, pip install, apt, git clone. ForestsNet Cloud doesn't count inbound traffic; only outbound is metered — docker push, artifact and cache uploads to GitHub, deployments.
  • A static IP. You can allow SSH on production servers only from the runner's address — see "Deploying from Git".

When you don't need one:

  • Public repositories. A pull request from a fork can run someone else's code on your server, and hosted runners are free for public repositories.
  • You need a clean environment. A persistent runner keeps whatever previous jobs left behind.
  • Infrequent builds. If the free minutes are enough, maintaining your own runner won't pay off.
  • iOS builds. They need a Mac; a Linux server cannot replace it. The Android emulator does start on our VPS — nested virtualization is enabled — but runs slower than on a dedicated server.

What you'll need

  • A separate ForestsNet Cloud VPS for CI running Ubuntu 22.04/24.04 or Debian 12; see "How many resources you need" for sizing. Don't put the runner on a production server.
  • SSH access with sudo — see "VNC console and first connection".
  • Admin rights on the repository, or owner rights in the organization.
  • Docker from the official repository if jobs build images or use container: and services: — see "Docker and Docker Compose".

Step 1. Prepare the server

Create a user without sudo rights and close every inbound port except 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

More on firewall rules in "The ufw firewall: managing ports".

Step 2. Download and register the runner

Open Settings → Actions → Runners → New self-hosted runner in the repository or organization and choose Linux x64. The page shows the commands and a registration token that expires after one hour.

Tip: take the version from the settings page, not from the releases list: new versions roll out gradually.

Commands for 2.337.0, with the SHA-256 checksum from the release notes:

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 installs the system libraries .NET needs and must run as root. Register as runner again: under root, config.sh stops with Must not run with sudo.

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

Useful config.sh options:

  • --labels — custom labels on top of the default self-hosted, Linux, X64. After registration, labels are changed only in the UI or via the API.
  • --replace — replace a runner with the same name. Reassign your custom labels afterwards.
  • --ephemeral and --disableupdate — see steps 5 and 7.

Step 3. Run the runner as a service

Instead of keeping ./run.sh open in a session, install a systemd service:

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

svc.sh creates a unit named actions.runner.<owner>-<repo>.<name>.service that runs as runner. Detailed logs are in _diag/Runner_*.log and _diag/Worker_*.log.

Done: journalctl -u 'actions.runner.*' -f shows Listening for Jobs, and the runner is Idle in the settings.
  • The runner reads environment variables from .env in its directory: KEY=value lines without quotes. Restart the service after editing it: sudo ./svc.sh stop && sudo ./svc.sh start.
  • The service's PATH was saved to .path at registration. If you install Go or Node.js later, add the path there and restart the service.

To unregister the runner, remove the service first, otherwise config.sh answers Uninstall service first. The removal token is on the runner's page.

cd /home/runner/actions-runner
sudo ./svc.sh uninstall
sudo -u runner ./config.sh remove --token REMOVAL_TOKEN

Step 4. Labels, runs-on and parallel jobs

A job goes to a runner that has every label listed in runs-on, with self-hosted first. Organization runners can also be selected by group:

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 keeps two deployments from running at once. If no matching runner exists, the job waits in the queue and is cancelled after 24 hours.

A runner handles one job at a time. For parallel jobs, unpack the archive into a second directory and register it under another name with its own service; a copy of an already configured directory fails with A session for this runner already exists. Each runner has its own _work, but Docker, the home directory and network ports are shared:

  • tests that listen on a fixed port fail in the second job with port is already allocated;
  • docker compose takes the project name from the directory name, which is the same for two checkouts of one repository, so jobs stop each other's containers. Set COMPOSE_PROJECT_NAME: ci-${{ github.run_id }}-${{ github.job }}.

Step 5. Ephemeral and JIT runners: a clean environment per job

A persistent runner accumulates state: files, containers, forgotten processes. One compromised job infects the machine for every job after it.

--ephemeral registers a runner for a single job but doesn't wipe the disk, so it only helps if the environment is recreated along with the runner. A JIT (just-in-time) runner is the same one-job runner without a registration token: the API returns a ready-made configuration for ./run.sh --jitconfig. The minimal setup is a loop that starts a fresh container from the official image for every job:

#!/usr/bin/env bash
# /usr/local/bin/ci-jit-loop.sh — one container per job
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

Run the loop as a systemd service so runner logs stay in journald. gh needs a token in GH_TOKEN that can manage runners: a classic token with the repo scope or a fine-grained one with Administration write access. The token stays on the host; only the one-time configuration goes into the container.

There's no Docker daemon inside the container, so container:, services: and image builds don't work in this setup, and mounting the host's /var/run/docker.sock would hand the job root on the host.

Step 6. Docker in jobs

Add the runner user to the docker group and restart the service:

sudo usermod -aG docker runner
cd /home/runner/actions-runner && sudo ./svc.sh stop && sudo ./svc.sh start
Important: the docker group is equivalent to root: a job can run docker run -v /:/host and take over the whole server. Use such a runner only on a dedicated server and only for trusted repositories.

Container jobs and service containers work only on Linux with Docker. The runner mounts the entire _work directory and the host's /var/run/docker.sock into the job container, so container: does not isolate the job from the server.

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-owned files in the work directory. Inside a container the job runs as root by default, and the next actions/checkout fails on its files with EACCES: permission denied (issue #434, open since 2020). The hook from step 7 fixes it, or run the container as the runner's UID via options: --user if the image allows it.
  • Ports bypass ufw. Docker opens published ports with its own iptables rules, bypassing ufw. From a container job, reach the service by name without ports:; on the runner host itself, publish the port on localhost only: ports: ["127.0.0.1:5432:5432"].
  • Image build cache. By default docker/setup-buildx-action removes its builder, cache included, at the end of the job — set driver: docker, or a fixed name together with keep-state: true. The type=gha cache is unnecessary on your own runner.
  • Rootless Docker limits the damage to the rights of the runner user; point the runner at the socket in .env: DOCKER_HOST=unix:///run/user/<UID>/docker.sock. Inside container jobs, though, Docker is then unavailable: the runner hard-codes the /var/run/docker.sock mount (issue #827).

Step 7. Maintenance: disk, caches, updates

What fills the disk

  • _work — checkouts, toolchains and downloaded actions; the runner never cleans them;
  • bin.* and externals.* directories of old versions left by auto-updates (issue #2708);
  • Docker images, volumes and build cache — usually the biggest part;
  • ~/.npm, ~/.cache/go-build and ~/go/pkg/mod of the runner user.

The runner rotates the logs in _diag itself and keeps them for 30 days.

Cleanup

# /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

Without -a, docker volume prune removes only anonymous volumes — the kind service containers such as postgres leave behind.

If the disk fills up within a day, add a hook: the script in ACTIONS_RUNNER_HOOK_JOB_COMPLETED runs after every job, the one in ACTIONS_RUNNER_HOOK_JOB_STARTED before it. Keep the script outside the runner directory, make it executable and give it a .sh extension; a non-zero exit code fails the job. This example fixes file ownership and removes stopped containers:

# line in /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, mode 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

Updates

Auto-update is on by default: the runner updates when it receives a job, or within a week of a release if it gets no jobs.

Since September 29, 2026, a runner that hasn't installed a new release within 30 days gets no jobs, and after a critical security update it gets none until the update is installed. An outdated runner receives Runner version v2.xxx.x is deprecated and cannot receive messages and stops together with its service. Use --disableupdate only if you update the runner yourself at least once a month. The API shows the deadline for your version in the runtime_deprecates_at field:

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

On Ubuntu and Debian, needrestart can restart the runner in the middle of a job after package upgrades. GitHub recommends excluding the runner service:

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

Step 8. Security

  • Private repositories only. That's GitHub's own advice: a pull request from a fork of a public repository can run any code on your machine. In a private repository anyone with read access can do the same if workflows from forks are allowed.
  • If a public repository is unavoidable. Enable Require approval for all external contributors, give your runner only push events on protected branches, and leave fork pull requests to hosted runners. Never run fork code under pull_request_target: that event has the base repository's secrets. Since mid-2026 actions/checkout refuses to fetch such code by default, and from November 2 a default policy will block this event in public repositories.
  • Runner groups should be open only to the repositories that need them. Public repositories are excluded by default — keep it that way.
  • Secrets. Every job can read whatever the runner user can: ~/.ssh, cloud tokens, other repositories' checkouts in _work, ~/.docker/config.json. For docker login, set DOCKER_CONFIG: ${{ runner.temp }}/docker. Environments with required reviewers don't help on a persistent runner: an earlier job can leave a process behind that captures the secrets. Narrow GITHUB_TOKEN with permissions:.
  • No sudo. In August 2026 the ChainDrop worm ran sudo python3 on CI runners to read secrets from the memory of the Runner.Worker process.
  • Supply chain. Pin actions to a full commit SHA: in March 2025 the tj-actions/changed-files tags were moved to a commit that printed secrets from runner memory into the logs. In November 2025 the Shai-Hulud 2.0 worm installed a runner named SHA1HULUD on infected machines and controlled them through Discussions, so review your runner list regularly. Install npm packages with --ignore-scripts where you can.
  • A separate server. A runner with Docker on a production server gives root to anyone who can push to the repository. Harden the CI server with "How to harden SSH on your VPS" and the "VPS security checklist".

GitLab Runner: installation and registration

The model is the same: the agent polls GitLab every 3 seconds, and no inbound ports are needed. For builds, use the docker executor: every job runs in a fresh container from the image set in .gitlab-ci.yml. If you host Git yourself, Forgejo has its own Actions — see "Self-hosted Git server with Forgejo".

Installation

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

The package creates the gitlab-runner user and service, with settings in /etc/gitlab-runner/config.toml. The service itself runs as root, so the docker executor doesn't need gitlab-runner in the docker group.

Installing or upgrading the package runs clear-docker-cache prune; in 19.2.0–19.2.2 a bug made it delete all unused images. To skip the cleanup: sudo NO_DOCKER_PRUNE=1 apt install gitlab-runner.

Registering with a glrt- token

Create the runner in the UI first: Settings → CI/CD → Runners → Create project runner, which requires the Maintainer role. Tags, Run untagged, protection and the timeout are set there, and you get a glrt- token that is shown only once:

sudo gitlab-runner register \
  --non-interactive \
  --url "https://gitlab.com/" \
  --token "glrt-YOUR_TOKEN" \
  --executor "docker" \
  --docker-image alpine:latest \
  --docker-pull-policy "if-not-present" \
  --description "vps-docker"
sudo gitlab-runner verify

Legacy registration tokens (--registration-token) are scheduled for removal in GitLab 20.0. A 410 Gone - runner registration disallowed response means they are already disabled.

config.toml: concurrency and 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 caps jobs for the whole file. Registration writes 1, so jobs run strictly one at a time.
  • limit caps a single runner; 0 means no limit.
  • pull_policy defaults to always; if-not-present is faster, but you have to refresh tags like latest yourself.
  • allowed_images is an allowlist of images jobs may use.

The file is re-read every 3 seconds, no restart needed.

privileged = true for Docker-in-Docker, like mounting /var/run/docker.sock, gives a job a path to root on the host. Safer options are rootless DinD (services_privileged with allowed_privileged_services), rootless BuildKit or Buildah; Kaniko is no longer maintained.

Cleanup and security

  • /usr/share/gitlab-runner/clear-docker-cache removes unused containers and volumes created by the runner. Run it from cron weekly at night — during a job it can remove that job's containers. Images built in jobs are left alone; docker system prune from step 7 handles them.
  • Mark the runner Protected so it only takes jobs from protected branches and tags.
  • A merge request pipeline from a fork runs in the fork's project by default. Running it in your project gives the fork's code your runner and variables, so do it only after reviewing the code.

How many resources you need

Per runner:

  • Linters, unit tests, deploys over SSH — 1–2 vCPU, 2 GB of RAM plus swap, 20–30 GB of disk.
  • Frontend builds: Next.js, Vite, webpack — 2 vCPU and 4 GB, with swap for large projects; otherwise expect JavaScript heap out of memory or exit code 137 from the OOM killer. For a 2 GB machine the Node.js docs suggest --max-old-space-size=1536.
  • Go — compilation scales with cores: 4 vCPU is noticeably faster than 2, and 2–4 GB of RAM is usually enough.
  • Docker images and tests with PostgreSQL or Redis — 4 vCPU, 8 GB and 60–80 GB of disk for images and build cache.
  • Each additional parallel runner — another 2 vCPU and 2–4 GB.

For comparison, a standard GitHub-hosted runner has 2 vCPU and 8 GB for private repositories and 4 vCPU and 16 GB for public ones, with a 14 GB disk.

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

To find the bottleneck, see "High load on a VPS". Monorepos and heavy emulator test suites are better off on a dedicated server.

Troubleshooting

The runner is Offline.

  • The service is stopped: sudo ./svc.sh status, journalctl -u 'actions.runner.*' -n 100; general steps are in "A service won't start".
  • The runner was started with ./run.sh and died with the SSH session. Install the service.
  • A self-update failed because the directory is owned by root or the disk is full. Check _diag/SelfUpdate-*.log and restore ownership: sudo chown -R runner:runner /home/runner/actions-runner.
  • No connection to GitHub: run ./config.sh --check --url https://github.com/OWNER/REPO --pat TOKEN with a token that has the workflow scope.
  • The runner was silent for 14 days and GitHub removed it — register it again.

Runner version … is deprecated and cannot receive messages.

  • It can no longer update itself: unpack the current archive into a new directory, register it under the same name with --replace, and remove the old service.

No space left on device.

  • df -h, docker system df and du -sh /home/runner/actions-runner/_work/* | sort -h show what grew. Cleanup is in step 7; finding the culprit is covered in "Running out of disk space".

Permission denied on /var/run/docker.sock.

  • The runner user isn't in the docker group, or the service wasn't restarted after adding it — see step 6.

The job hangs on "Waiting for a runner to pick up this job".

  • No runner has every label from runs-on, or the matching one is busy, Offline or outdated — compare the labels on the runner's page.
  • The runner group isn't available to this repository or doesn't allow public repositories.

checkout fails with EACCES.

  • Root-owned files from a container job are left in _work. One-off fix: sudo chown -R runner:runner /home/runner/actions-runner/_work; permanent fix: the hook from step 7.

GitLab: "This job is stuck".

  • No online runner has the job's tags, Run untagged is off, or the runner is Protected while the branch isn't.
  • concurrent = 1 and the runner is busy. sudo gitlab-runner verify checks the registration.

Sources