SSD Nodes Learn 8GB RAM — $66/yr
Guides Matt ConnorBy Matt Connor

Self-hosted GitHub Actions runner on a VPS

Register a self-hosted GitHub Actions runner on Ubuntu 24.04: dedicated user, checksum, config.sh, a systemd service, and the fork pull request risk.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, July 30, 2026.

What a self-hosted GitHub Actions runner does

A self-hosted GitHub Actions runner is a program you install on your own VPS that asks GitHub for jobs and runs them on your hardware. You register it against one repository, install it as a systemd service, and it comes back after every reboot. GitHub schedules the job. Your server does the work.

CI (continuous integration) on a box you own is worth it for two reasons. Build minutes stop being metered, and a job can reach things only your machine has, like a warm build cache or a private network. The price is security. The runner executes whatever the workflow file says, as the user you gave it, so a workflow file is remote code execution by design. On a private repository that is fine, because only people you trust can add one. On a public repository it is a real risk, and the section on fork pull requests explains the mechanism.

Everything below is Ubuntu 24.04 with runner version 2.336.0, the current release as of July 2026.

What you need before you start

Start from a VPS with an ordinary admin account and sudo, the state you reach in the first ten minutes on a new VPS. You do not need to open an inbound port. The runner opens an outbound HTTPS (hypertext transfer protocol secure) connection to GitHub and holds it open while it waits for work, so GitHub never connects to your server. Your firewall can stay shut to the world and jobs still arrive.

You also need admin rights on the repository, because the registration token is shown in the repository settings.

Create a dedicated user for the runner

Never run the runner as root or as your own admin user. Every job inherits the runner user's rights, so a workflow that calls sudo succeeds if the runner user can use sudo. Make one unprivileged user that owns nothing except its own home directory. Least privilege user accounts on a VPS covers the general pattern. Here is the specific one.

sudo useradd -m -s /bin/bash gharunner
sudo passwd -l gharunner
sudo chmod 750 /home/gharunner
sudo install -d -m 700 -o gharunner -g gharunner /home/gharunner/actions-runner

passwd -l locks the password, so nobody can log in as gharunner with one. Mode 700 on the runner directory matters because the runner stores its credentials there in cleartext, and a checkout can hold private source.

Check both properties before you go further:

sudo passwd -S gharunner
sudo -l -U gharunner

passwd -S prints a line starting gharunner L, where L means the password is locked. sudo -l -U gharunner should answer with is not allowed to run sudo. If it prints a list of permitted commands instead, the account is in a sudo group and the isolation you just built is gone.

Download the runner and check the tarball

Work as the runner user from here.

sudo -iu gharunner
cd ~/actions-runner
RUNNER_VERSION=2.336.0
curl -fL -o actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz \
  "https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"

Run uname -m first if you are not sure of the architecture. x86_64 takes the linux-x64 file above. aarch64 takes actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz.

Now verify what you downloaded. The SHA256 (secure hash algorithm, 256 bit) below is for the 2.336.0 x64 tarball. GitHub prints the value for the current release on the release page and on the New self-hosted runner screen. It changes with every version, so copy it from there when you install a different one.

echo "04cf0be1aff4c3ec3554466c39124ca250e3effd8873bb7e8d68535aa9505d5d  actions-runner-linux-x64-2.336.0.tar.gz" | sha256sum -c

A good download prints one line:

actions-runner-linux-x64-2.336.0.tar.gz: OK

A truncated or altered file prints the failure and a warning:

actions-runner-linux-x64-2.336.0.tar.gz: FAILED
sha256sum: WARNING: 1 computed checksum did NOT match

Do not skip the check and let tar find the problem instead. A half-written archive fails with gzip: stdin: unexpected end of file and tar: Unexpected EOF in archive, which tells you the file is broken but not whether it was cut short or replaced.

tar xzf ./actions-runner-linux-x64-2.336.0.tar.gz
ls

What the tarball contains, and what it does not

After extraction the directory holds config.sh, run.sh, env.sh, safe_sleep.sh, bin/ and externals/. bin/ holds the runner binaries and bin/installdependencies.sh. externals/ holds the bundled Node runtime that JavaScript actions execute on.

There is no svc.sh yet. GitHub's documentation describes it as the script "that is created after successfully adding the runner", because it is written from a template with your repository and runner name baked into the service name. So sudo ./svc.sh install before ./config.sh fails with sudo: ./svc.sh: command not found. Register first, then install the service.

Install the runner dependencies

The runner is a .NET application, so it needs a few shared libraries. Leave the runner user's shell and install them with sudo, because the script writes to the system package database.

exit
cd /home/gharunner/actions-runner
sudo ./bin/installdependencies.sh

On Ubuntu 24.04 that pulls libkrb5-3, zlib1g, liblttng-ust1t64, libssl3t64 and libicu74. The script tries several version names for each library and keeps the one your release ships, which is why the same script works on older Ubuntu and on Debian.

Skip this step and ./config.sh stops before it does anything:

Dependencies is missing for Dotnet Core 6.0
Execute sudo ./bin/installdependencies.sh to install any missing Dotnet Core 6.0 dependencies.

A missing libicu gives the same advice under a different first line, Libicu's dependencies is missing for Dotnet Core 6.0. Both come from the same place: config.sh runs ldd against the bundled libraries before it starts, so an unresolved link stops the script instead of producing a confusing crash later.

Register the runner with your repository

Get a token from the repository. Open Settings, then Actions, then Runners, then New self-hosted runner. The page shows a registration token that starts with A. It expires one hour after it is created, so generate it when you are ready to paste it.

Register as the runner user. config.sh refuses to run under sudo.

sudo -iu gharunner
cd ~/actions-runner
./config.sh --url https://github.com/YOUR-USER/YOUR-REPO \
  --token PASTE_REGISTRATION_TOKEN_HERE \
  --name vps-runner-1 \
  --labels vps \
  --work _work \
  --unattended \
  --replace

What those flags do. --name is how the runner appears in the repository, so pick something you will still recognise in six months. --labels adds your own labels; the runner already carries self-hosted, Linux and X64 without being asked. --work names the directory where checkouts land, inside the runner directory. --unattended answers the interactive prompts with their defaults, which is what you want when the command sits in a script. --replace takes over an existing registration of the same name instead of failing, which is what you want when you rebuild the server.

A successful run ends with these lines:

√ Runner successfully added
√ Runner connection is good
√ Settings Saved.

The registration now lives in the runner directory as .runner, .credentials and .credentials_rsaparams. The last two identify this runner to GitHub, so anyone who can read them can impersonate it. That is the reason the directory is mode 700 and the user has no sudo.

Install the runner as a systemd service

./run.sh in a terminal is fine for one test, but it dies with your SSH session. Install the service so the runner starts at boot. systemd services and timers on a VPS explains the unit files themselves. Here svc.sh writes one for you.

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

svc.sh requires root because it writes a unit into /etc/systemd/system and enables it. The argument after install is the user the service runs as. Pass gharunner explicitly. With no argument the script falls back to $SUDO_USER, which is your admin account, and then every job runs as a user that can use sudo.

The unit is named after the repository and the runner, in the form actions.runner.YOUR-USER-YOUR-REPO.vps-runner-1.service. You never have to type that out:

systemctl list-units 'actions.runner.*'
sudo journalctl -u 'actions.runner.*' -n 20 --no-pager

A healthy runner logs √ Connected to GitHub and then a line ending in Listening for Jobs, and the repository's Runners page shows it as Idle. A runner shown as Offline is either not running or cannot reach GitHub on port 443.

Send a job to the runner

runs-on selects a runner by label. Ask for self-hosted plus your own label, so a job cannot land on a runner you did not mean.

name: build
on:
  push:
    branches: [main]
jobs:
  build:
    runs-on: [self-hosted, linux, vps]
    steps:
      - uses: actions/checkout@v5
      - run: uname -a

If the job waits at Waiting for a runner to pick up this job, the labels do not match. Every label in runs-on must exist on the runner, so one extra word leaves the job queued with no error anywhere. Compare the list against the labels shown next to the runner in the repository settings.

Why self-hosted runners and public repositories do not mix

This is the part people skip. GitHub's guidance is blunt: self-hosted runners "should almost never be used for public repositories", and they "do not have guarantees around running in ephemeral clean virtual machines, and can be persistently compromised by untrusted code in a workflow".

The mechanism is simple. A pull request from a fork brings its own copy of the workflow file. If your public repository runs pull request workflows on your runner, then anyone who can fork the repository can propose a workflow that runs their commands on your VPS. They need no write access, because the thing they are proposing is the thing that runs.

Approval settings soften this without fixing it. The default policy for a public repository asks a maintainer to approve a first-time contributor's fork workflow. After you approve that person once, their later pull requests run without a new prompt. So the gate is a human reading a diff, every time, and a payload hidden three levels down in a build script is easy to miss.

A fork pull request does not receive your secrets, and its GITHUB_TOKEN is read only. That limits the damage inside GitHub. It does nothing for your server. The attacker has a shell as gharunner, so they can read every file that user can read, reach anything the VPS can reach on its private network, and leave something behind in ~/.bashrc or a user systemd unit that runs during the next job.

Registering with --ephemeral makes the runner accept one job and then deregister, so one job cannot read the next job's workspace. It helps only if something rebuilds the machine or the container for each job, because a backdoor written into the runner user's home directory survives a fresh registration.

The rules that follow are short. Use self-hosted runners for private repositories. If you must attach one to a public repository, do not run fork pull requests on it, keep nothing else on that server, and treat the machine as disposable.

Docker jobs, and the group that is really root

Container jobs, service containers and any workflow step that calls docker build need a Docker daemon on the runner host. Install Docker in the usual way, which Docker and Docker Compose on a VPS covers, then add the runner user to the docker group.

Understand the trade before you do it. Membership of the docker group is equivalent to root, because a container can bind mount / and run as root inside it. So a workflow that can talk to the Docker socket can read and write every file on the VPS, including /etc/shadow. On a private repository with trusted contributors that may be an acceptable price. Anywhere else it removes the point of the unprivileged user. Rootless Docker keeps container builds inside the runner user's own rights, at the cost of a slower storage driver and no privileged containers.

Updates, and removing the runner cleanly

A self-hosted runner updates itself by default. It notices a new release, replaces its own files and restarts the service, so normally you do nothing. ./config.sh --disableupdate turns the self-update off when you need a fixed version. After that, updating is your job: GitHub's documentation is explicit that a runner configured with --disableupdate has to be updated by hand.

A manual update keeps the registration, because .runner and .credentials are not in the tarball. Stop the service, download and checksum the new tarball as gharunner, extract it over the same directory with tar xzf, then start the service again:

cd /home/gharunner/actions-runner
sudo ./svc.sh stop
sudo ./svc.sh start

To remove the runner, uninstall the service first, then deregister. The removal token comes from the same Runners page, under the runner's own Remove button.

cd /home/gharunner/actions-runner
sudo ./svc.sh stop
sudo ./svc.sh uninstall
sudo -iu gharunner
cd ~/actions-runner
./config.sh remove --token PASTE_REMOVAL_TOKEN_HERE

Deleting the directory without deregistering leaves the runner listed as Offline in the repository, because GitHub only learns it is gone when the runner says so or an admin deletes the entry by hand.

Failure modes, with the strings you will see

Must not run with sudo. config.sh prints this and exits when it is run as root. The check is deliberate, because root-owned files in _work break every later job that runs as the service user. Run ./config.sh as gharunner. The RUNNER_ALLOW_RUNASROOT variable overrides the check, and using it only moves the breakage later.

sudo: ./svc.sh: command not found. You are in the right directory. svc.sh does not exist yet, because config.sh has not completed a registration. Register the runner, then install the service.

Http response code: NotFound from 'POST https://api.github.com/actions/runner-registration'. The token is not a valid registration token. Either it expired, since they last one hour, or a personal access token was pasted in place of the registration token from the Runners page. Generate a fresh token and paste it again.

Dependencies is missing for Dotnet Core 6.0. Run sudo ./bin/installdependencies.sh from the runner directory as root, then register again.

Runner Offline after a reboot. Run systemctl is-enabled 'actions.runner.*'. If nothing is listed, ./svc.sh install was never run, so the runner only ever existed inside your terminal session. If the unit is enabled and the runner is still Offline, read journalctl -u 'actions.runner.*' and check outbound HTTPS.

The disk fills up. Checkouts, build caches and Docker images accumulate under _work and in the runner user's home, and nothing prunes them for you. Watch du -sh /home/gharunner/actions-runner/_work and add a scheduled clean-up before the disk decides for you.

FAQ

Why does sudo ./svc.sh install say command not found?

Because svc.sh is not in the runner tarball. It is generated in the runner directory when ./config.sh finishes registering, using your repository and runner name to build the service name. Run ./config.sh as the runner user first. After that, sudo ./svc.sh install gharunner finds the script and writes a unit named actions.runner.OWNER-REPO.RUNNER-NAME.service into /etc/systemd/system.

Do I need to open a firewall port for a self-hosted runner?

No. The runner opens an outbound HTTPS connection to GitHub and holds it open while it waits for jobs, so GitHub never initiates a connection to your VPS. Allow outbound 443 and leave your inbound rules closed. If the runner shows Offline while its service is running, look at outbound filtering and DNS rather than at inbound rules.

Can I use a self-hosted runner on a public repository?

You can, and GitHub advises against it. A pull request from a fork carries its own workflow file, so anyone who can fork your repository can propose commands that run on your machine. The approval prompt covers only a contributor's first run. If you attach a runner to a public repository, disable fork pull request workflows on it, keep nothing else on that server, and rebuild the machine on a schedule.

Why does registration fail with Http response code: NotFound?

The registration call answers NotFound when the credential is wrong, not only when the URL is wrong, which makes the message misleading. Registration tokens expire one hour after they are shown, and a personal access token is not accepted for this call. Open Settings, Actions, Runners, New self-hosted runner again, copy the fresh token, and confirm the --url value points at a repository where you have admin rights.

#github-actions#ci#self-hosted#runner#ubuntu-24-04