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: dedicated user, checksum, config.sh, systemd service, plus fork PR risk.
Wetin self-hosted GitHub Actions runner dey do
Self-hosted GitHub Actions runner na program wey you install for your own VPS. E dey ask GitHub for jobs, then e run dem for your hardware. You register am against one repository, install am as systemd service, and e go come back after every reboot. GitHub dey schedule the job. Na your server dey do the work.
CI (continuous integration) for machine wey you own worth am for two reasons. Build minutes no go dey counted 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 workflow file talk, with the user account 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 am. For public repository, e be real risk. The section about fork pull requests explain how the mechanism work.
Everything for this guide 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, for the state wey you reach for the first ten minutes for a new VPS. You no need open inbound port. The runner go open outbound HTTPS (hypertext transfer protocol secure) connection go 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 the whole internet and jobs still go arrive.
You also need admin rights for the repository, because the registration token dey show for the repository settings.
Make dedicated user for the runner
No ever run the runner as root or as your own admin user. Every job dey inherit the runner user's permissions, so workflow wey call sudo go succeed if the runner user fit use sudo. Make one unprivileged user wey no own anything apart from im own home directory. Least privilege user accounts for VPS explain the general pattern. Na the specific one be this.
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-runnerpasswd -l lock the password, so nobody fit log in as gharunner with am. Mode 700 for the runner directory dey important because the runner store im credentials there as cleartext, and checkout fit contain private source code.
Check both properties before you continue:
sudo passwd -S gharunner
sudo -l -U gharunnerpasswd -S print one line wey start with gharunner L, where L mean say the password dey locked. sudo -l -U gharunner suppose answer with is not allowed to run sudo. If e print list of permitted commands instead, the account dey inside sudo group and the isolation wey you just build 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 of the architecture. x86_64 dey take the linux-x64 file wey dey above. aarch64 dey take 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 this value for the current 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 -cCorrect download go print one line:
actions-runner-linux-x64-2.336.0.tar.gz: OKTruncated or changed file go print failure message and warning:
actions-runner-linux-x64-2.336.0.tar.gz: FAILED
sha256sum: WARNING: 1 computed checksum did NOT matchNo skip this check and allow tar discover the problem later. Archive wey no write finish go fail with gzip: stdin: unexpected end of file and tar: Unexpected EOF in archive. This one go tell you say the file spoil, but e no go tell you whether download stop early or somebody replace am.
tar xzf ./actions-runner-linux-x64-2.336.0.tar.gz
lsWetin dey inside the tarball, and wetin no dey inside
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 run 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 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 am first, then install the service.
Install runner dependencies
Runner na .NET application, so e need some shared libraries. Stay for runner user shell and install dem with sudo, because script dey write to system package database.
exit
cd /home/gharunner/actions-runner
sudo ./bin/installdependencies.shFor Ubuntu 24.04, command go install libkrb5-3, zlib1g, liblttng-ust1t64, libssl3t64 and libicu74. Script dey try different version names for each library and keep the one wey your release ship. 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, you go see the same advice under another first line, Libicu's dependencies is missing for Dotnet Core 6.0. Both messages come from the same place: config.sh dey run ldd against the bundled libraries before e start. So, if any link no resolve, 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 go expire one hour after dem create am, so generate am when you ready to paste am.
Register am as the runner user. config.sh no gree 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 \
--replaceWetin those flags dey do. --name na how runner go appear for the repository, so choose name wey you go still recognise after six months. --labels dey add your own labels; runner already get self-hosted, Linux and X64 without you asking. --work dey name the directory where checkouts go land, inside runner directory. --unattended dey answer interactive prompts with their default values, and na wetin you want when command dey inside script. --replace dey take over existing registration with 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 don now dey inside 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 systemd service
./run.sh for terminal dey okay for one test, but e go die when your SSH session end. Install the service so runner go start when system boot. systemd services and timers for VPS explain the unit files themselves. For 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 statussvc.sh need root because e dey write unit inside /etc/systemd/system and enable am. The argument after install na the user wey service go run as. Pass gharunner clearly. If you no pass argument, script go use $SUDO_USER, wey be your admin account. Then every job go run as user wey fit use sudo.
The unit name come from 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-pagerHealthy runner dey log √ Connected to GitHub, then e go show one line wey end with Listening for Jobs. 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 the runner
runs-on dey select runner by label. Request 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 -aIf job dey wait for Waiting for a runner to pick up this job, the labels no match. Every label for runs-on must dey on the runner, so one extra word go leave the job queued without error anywhere. Compare the list with the labels wey dey show beside the runner for repository settings.
Why self-hosted runners and public repositories no dey mix
Na this part people dey skip. GitHub guidance dey direct: self-hosted runners “almost never” suppose dey used for public repositories, and dem “no get guarantee say dem go run inside ephemeral clean virtual machines, and untrusted code for workflow fit compromise dem continuously”.
The way e happen simple. Pull request from fork dey bring its own copy of 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. 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 wey dey read diff every time, and payload wey hide three levels inside build script fit easy to miss.
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. Attacker get shell as gharunner, so dem fit read every file wey that user fit read, reach anything wey VPS fit reach for its private network, and leave something behind for ~/.bashrc or inside user systemd unit wey go run during the next job.
Registering with --ephemeral make runner accept one job, then deregister, so one job no fit read workspace from the next job. 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 for docker group dey equivalent to 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 trusted contributors dey use, this fit be acceptable price. Anywhere else, e remove the reason for using unprivileged user. Rootless Docker dey keep container builds inside the runner user's own permissions, but storage driver dey slower and privileged containers no dey available.
Updates, plus how to remove the runner cleanly
Self-hosted runner dey update itself by default. E go notice new release, replace im own files, then restart the service, so normally you no need do anything. ./config.sh --disableupdate go disable self-update when you need fixed version. After that, na you go handle updates: GitHub documentation clear say runner wey configure with --disableupdate must update by hand.
Manual update no go affect registration, because .runner and .credentials no dey inside the tarball. Stop the service, download and checksum 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 startTo 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_HEREIf you delete the directory without deregistering the runner, e go remain listed as Offline for the repository. GitHub only know say e don comot when the runner report am, or when admin delete the entry by hand.
Wahala wey fit happen, plus the messages wey you go see
Must not run with sudo. config.sh dey print this message and e go exit if you run am as root. The check dey intentional, because files wey root own for _work go spoil every later job wey dey run as the service user. Run ./config.sh as gharunner. The RUNNER_ALLOW_RUNASROOT variable fit override the check, but if you use am, na to shift the wahala go later.
sudo: ./svc.sh: command not found. You dey the correct directory. svc.sh never exist yet because config.sh never finish 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 dey 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 a reboot. Run systemctl is-enabled 'actions.runner.*'. If nothing show, ./svc.sh install never run before, 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 for 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 full by itself.
FAQ
Why sudo ./svc.sh install dey talk say command no found?
Because svc.sh no dey inside the runner tarball. ./config.sh dey generate am for runner directory when e finish registration, using your repository and runner name to build the service name. Run ./config.sh first as the runner user. After that, sudo ./svc.sh install gharunner go find the script and write a 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 go GitHub and keep am open while e dey wait for jobs, so GitHub no dey initiate connection go 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 its 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, keep nothing else for that server, and rebuild the machine according to schedule.
Why registration dey fail with Http response code: NotFound?
The registration call answer NotFound when credential wrong, no be only when URL wrong, and this one 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.