SSD Nodes Learn 8GB RAM — $66/yr
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-02

How to Set Up GitHub Actions Runner for VPS

Set up a self-hosted GitHub Actions runner on Ubuntu 24.04 with runner 2.336.0, checksum check, config.sh, systemd, and fork pull request security risk.

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

Wetin a self-hosted GitHub Actions runner dey do

A self-hosted GitHub Actions runner na program wey you install for your own VPS. E dey ask GitHub for jobs and run dem for your hardware. You register am against one repository, install am as a systemd service, and e go come back after every reboot. GitHub dey schedule the job. Your server dey do the work.

CI (continuous integration) for machine wey you own dey useful for two reasons. Build minutes no go dey measured again, and job fit reach things wey na only your machine get, like warm build cache or private network. But security na the price. The runner dey execute anything wey the workflow file talk, as the user wey you give am. So, workflow file na remote code execution by design. For private repository, this one dey okay because na only people wey you trust fit add one. For public repository, e be real risk, and the section about fork pull requests dey explain how e dey work.

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

Wetin you need before you start

Start with VPS wey get normal admin account and sudo, like the state wey you reach for di first ten minutes for new VPS. You no need open any inbound port. The runner go open outbound HTTPS (hypertext transfer protocol secure) connection to GitHub and keep am open while e dey wait for work. So GitHub no ever connect to your server. Your firewall fit remain closed to everybody, and jobs still go arrive.

You also need admin rights for the repository, because registration token dey show for the repository settings.

Make one special user for the runner

No run the runner as root or as your own admin user. Every job gets the runner user's permissions, so any workflow wey dey call sudo go work if the runner user fit use sudo. Make one user without extra privileges, wey no own anything except its own home directory. Least privilege user accounts for VPS explains the general pattern. Na the specific setup be this 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 fit log in as gharunner with password. Mode 700 for the runner directory dey important because the runner stores its credentials there as cleartext, and a checkout fit contain private source code.

Check both properties before you continue:

sudo passwd -S gharunner
sudo -l -U gharunner

passwd -S prints one line wey starts with gharunner L. L means the password dey locked. sudo -l -U gharunner suppose answer with is not allowed to run sudo. If e prints a list of permitted commands instead, the account dey inside a sudo group, and the isolation wey you just create don disappear.

Download the runner and check the tarball

From here, work as the runner user.

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 no sure about the architecture. x86_64 dey use the linux-x64 file wey dey above. aarch64 dey use actions-runner-linux-arm64-${RUNNER_VERSION}.tar.gz.

Now verify wetin you download. The SHA256 (secure hash algorithm, 256 bit) wey dey below na for the 2.336.0 x64 tarball. GitHub dey show the value for the current release for the release page and for the New self-hosted runner screen. E dey change for every version, so copy am from there when you install another one.

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

Correct download go print one line:

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

File wey dem truncate or alter go print the failure and one warning:

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

No skip this check and allow tar find the problem later. Archive wey dem write only partly go fail with gzip: stdin: unexpected end of file and tar: Unexpected EOF in archive. This one tells you say the file spoil, but e no tell you whether dem cut am short or replace am.

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

Wetin the tarball get, and wetin e no get

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

No svc.sh dey yet. GitHub documentation describe am as the script "wey dem create after dem successfully add the runner", because dem write am from template wey get your repository name and runner name inside the service name. So sudo ./svc.sh install before ./config.sh go fail with sudo: ./svc.sh: command not found. Register first, then install the service.

Install the runner dependencies

The runner na .NET application, so e need some shared libraries. Leave the runner user's shell as e be and install dem with sudo, because the script dey write to the system package database.

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

For Ubuntu 24.04, this one dey install libkrb5-3, zlib1g, liblttng-ust1t64, libssl3t64 and libicu74. The script dey try different version names for each library and keep the one wey your release ships, na why the same script dey work for older Ubuntu and Debian.

If you skip this step, ./config.sh go stop before e do anything:

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

If libicu no dey, e go give the same advice under another first line, Libicu's dependencies is missing for Dotnet Core 6.0. Both errors come from the same place: config.sh dey run ldd against the bundled libraries before e start, so if any link no resolve, the script go stop instead of causing confusing crash later.

Register runner with your repository

Get token from the repository. Open Settings, then Actions, then Runners, then New self-hosted runner. The page go show registration token wey start with A. E dey expire one hour after dem create am, so generate am when you ready to paste am.

Register as the runner user. config.sh no fit 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

Wetín those flags dey do. --name na how the runner go appear for the repository, so choose name wey you still go recognise after six months. --labels dey add your own labels; the runner already get self-hosted, Linux and X64 without anybody asking am. --work dey name the directory wey checkouts go enter, inside the runner directory. --unattended dey answer the interactive prompts with their defaults, and na wetin you want when the command dey inside a script. --replace dey take over existing registration wey get the same name instead of failing, and na wetin you want when you rebuild the server.

Successful run go end with these lines:

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

The registration now dey inside the runner directory as .runner, .credentials and .credentials_rsaparams. The last two dey identify this runner to GitHub, so anybody wey fit read dem fit impersonate am. Na why the directory get mode 700 and the user no get sudo.

Runner install as a systemd service

./run.sh for terminal testing dey okay, but e go stop when your SSH session close. Install the service so the runner go start when system boot. systemd services and timers for VPS explains the unit files themselves. Here svc.sh go write one for you.

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

svc.sh need root because e dey write one unit inside /etc/systemd/system and enable am. The argument after install na the user wey the service go run as. Pass gharunner clearly. If you no give argument, the script go use $SUDO_USER, wey be your admin account, and then every job go run as user wey fit use sudo.

The unit name dey follow the repository and runner, for this format actions.runner.YOUR-USER-YOUR-REPO.vps-runner-1.service. You no need type am yourself:

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

Healthy runner dey log √ Connected to GitHub and then one line wey end with Listening for Jobs, and the repository's Runners page go show am as Idle. If runner show as Offline, e either no dey run or e no fit reach GitHub through port 443.

Send job go runner

runs-on dey select runner by label. Ask for self-hosted plus your own label, so job no go land for runner wey you no intend.

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

If job dey wait for Waiting for a runner to pick up this job, the labels no match. Every label for runs-on must dey exist for runner, so one extra word go leave job queued without any error anywhere. Compare the list with the labels wey show beside runner for repository settings.

Why self-hosted runners and public repositories no dey mix

Na this part people dey skip. GitHub guidance talk am direct: self-hosted runners “should almost never be used for public repositories”, and dem “do not have guarantees around running in ephemeral clean virtual machines, and can be persistently compromised by untrusted code in a workflow”.

The way e dey work simple. A pull request from a fork carry its own copy of the workflow file. If your public repository dey run pull request workflows for your runner, anybody wey fit fork the repository fit propose workflow wey go run their commands for your VPS. Dem no need write access, because the thing wey dem dey propose na the thing wey go run.

Approval settings fit reduce the risk, but dem no solve am. The default policy for public repository dey ask maintainer to approve first-time contributor fork workflow. After you approve that person once, their later pull requests go run without new prompt. So the gate na human being wey dey read diff every time, and payload wey hide three levels inside build script fit easy escape notice.

A fork pull request no dey receive your secrets, and its GITHUB_TOKEN na read only. This one limit the damage inside GitHub. E no do anything for your server. The attacker get shell as gharunner, so dem fit read every file wey that user fit read, reach anything wey the VPS fit reach for its private network, and leave something for ~/.bashrc or a user systemd unit wey go run during the next job.

Registering with --ephemeral make the runner accept one job and then deregister, so one job no fit read the next job workspace. E help only if something dey rebuild the machine or container for every job, because backdoor wey dem write inside runner user's home directory go survive fresh registration.

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

Docker jobs, and the group wey really be root

Container jobs, service containers and any workflow step wey dey call docker build need Docker daemon for the runner host. Install Docker the normal way, as Docker and Docker Compose for VPS explain, then add the runner user to docker group.

Understand the trade-off before you do am. Membership of docker group na the same thing as root, because container fit bind mount / and run as root inside am. So, workflow wey fit talk to Docker socket fit read and write every file for the VPS, including /etc/shadow. For private repository wey get trusted contributors, this fit be acceptable price. Anywhere else, e remove the reason for using unprivileged user. Rootless Docker keep container builds inside the runner user's own permissions, but storage driver go slower and privileged containers no go work.

Updates, and how to remove the runner cleanly

Self-hosted runner dey update itself by default. E dey notice new release, replace its own files, and restart the service, so normally you no need do anything. ./config.sh --disableupdate dey turn off self-update when you need fixed version. After that, na you go update am: GitHub documentation talk clearly say runner wey you configure with --disableupdate must get update by hand.

Manual update dey keep the registration, because .runner and .credentials no dey inside the tarball. Stop the service, download and check the checksum of the new tarball as gharunner, extract am 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 am. The removal token dey come from the same Runners page, under the runner 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

If you delete the directory without deregistering, the runner go remain listed as Offline for the repository, because GitHub only know say e don comot when the runner talk so or an admin delete the entry by hand.

Failure modes, wit di strings wey you go see

Must not run with sudo. config.sh dey print dis message and exit when dem run am as root. Dis check na deliberate, because files wey root own for _work fit break every later job wey dey run as the service user. Run ./config.sh as gharunner. RUNNER_ALLOW_RUNASROOT variable dey override the check, and if you use am, e go only move the problem go later.

sudo: ./svc.sh: command not found. You dey for the correct directory. svc.sh never exist yet, because config.sh never complete registration. Register the runner, then install the service.

Http response code: NotFound from 'POST https://api.github.com/actions/runner-registration'. The token no be valid registration token. E fit don expire, because dem last only one hour, or person paste personal access token instead of the registration token from the Runners page. Generate fresh token and paste am 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 reboot. Run systemctl is-enabled 'actions.runner.*'. If nothing show for the list, dem never run ./svc.sh install, so the runner only exist inside your terminal session. If the unit dey enabled and the runner still dey Offline, read journalctl -u 'actions.runner.*' and check outbound HTTPS.

The disk fills up. Checkouts, build caches and Docker images dey gather under _work and inside the runner user's home, and nothing dey prune dem for you. Monitor du -sh /home/gharunner/actions-runner/_work and add scheduled clean-up before the disk decide for you.

FAQ

Why sudo ./svc.sh install dey talk command not found?

Na because svc.sh no dey inside the runner tarball. E dey generate inside runner directory when ./config.sh finish registration, using your repository and runner name to build the service name. First run ./config.sh as the runner user. After that, sudo ./svc.sh install gharunner go find the script and write unit wey dem name actions.runner.OWNER-REPO.RUNNER-NAME.service inside /etc/systemd/system.

I need open firewall port for self-hosted runner?

No. The runner dey open outbound HTTPS connection to GitHub and keep am open while e dey wait for jobs. So GitHub no dey initiate connection to your VPS. Allow outbound 443 and leave your inbound rules closed. If runner show Offline while e service dey run, check outbound filtering and DNS instead of inbound rules.

I fit use self-hosted runner for public repository?

You fit, but GitHub no advise am. Pull request from fork carry im own workflow file. So anybody wey fit fork your repository fit propose commands wey go run for your machine. The approval prompt cover only contributor first run. If you attach runner to public repository, disable fork pull request workflows for am, no keep anything else for that server, and rebuild the machine on schedule.

Why registration dey fail with Http response code: NotFound?

The registration call dey answer NotFound when credential wrong, no be only when URL wrong. Na this one dey make the message misleading. Registration tokens dey expire one hour after dem show am, and personal access token no dey accepted for this call. Open Settings, Actions, Runners, New self-hosted runner again. Copy the fresh token, and confirm say --url value dey point to repository wey you get admin rights for.

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