SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-10-28

How to setup self-hosted GitHub Actions runner on VPS

Learn how to setup a self-hosted GitHub Actions runner on Ubuntu 24.04. We show you how to create a dedicated user, verify checksums, run config.sh and fix security risks.

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

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 and 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. Your server dey do the work.

CI (continuous integration) for machine wey you own make sense for two reasons. Build minutes no go dey count 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 go execute anything wey workflow file talk, with 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 am. For public repository, e be real risk, and the section about fork pull requests explain how the mechanism work.

Everything for here use Ubuntu 24.04 with runner version 2.338.0, wey be the current release as of 12 October 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-runner

passwd -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 gharunner

passwd -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.338.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.338.0 x64 tarball. GitHub dey show the 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 "af4b794c1bc41d73d40535e3fe092a39f9679cd8d965954c2aca25a05ca41d32  actions-runner-linux-x64-2.338.0.tar.gz" | sha256sum -c

Correct download go print one line:

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

If file cut short or dem change am, e go print failure message and warning:

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

No skip this check and allow tar discover the problem later. Archive wey dem write halfway 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 cut short or somebody replace the file.

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

Wetin 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. No comot from runner user shell; use sudo install dem, because script dey write to system package database.

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

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

If you skip this step and one of those libraries no dey, ./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, but with different first line, Libicu's dependencies is missing for Dotnet Core 6.0. Both messages come from the same place: config.sh checks the bundled libraries with ldd, and libicu with ldconfig, before e start. So if any link no resolve, script go stop instead of causing confusing crash later.

Register the runner with your repository

Collect token from the repository. Open Settings, then Actions, then Runners, then New self-hosted runner. The page go show registration token. E dey expire one hour after you 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 \
  --replace

Wetin those flags dey do. --name na the name wey runner go show for the repository, so choose one 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 the 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 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 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 status

svc.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-pager

Healthy 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 -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 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.

Kí ló dé tí self-hosted runners àti public repositories ò yẹ kí dem jọ

Na this part people dey skip. GitHub guidance clear well-well: 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. Pull request from fork dey bring 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 dey reduce the risk, but dem no solve am. The default policy for public repository dey ask maintainer to approve first-time contributor's fork workflow. GitHub dey count person as first-time only until any commit or pull request from the person don merge into the repository. After that, the person's later pull requests go run without prompt, and GitHub itself dey warn say one small merged fix don already reach that point. So the gate na human being wey dey read diff, and payload wey hide three levels inside build script fit easy escape notice.

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 private network, and leave something inside ~/.bashrc or user systemd unit wey go run during the next job.

Registering with --ephemeral go make the runner accept one job and then deregister, so one job no fit read the next job workspace. E only help 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. If wetin the public repository need na only to deploy go your VPS, make runner wey GitHub dey host join your tailnet as ephemeral node for that one job instead, and how Tailscale ephemeral nodes dey chop your 1,000 free minutes go show you whether the free plan go reach.

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, 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. To disable self-update, pass --disableupdate to ./config.sh when you register the runner. For runner wey you don register already, you need register am again with the flag. This no mean say you fit remain on one version forever. GitHub documentation talk am clearly: runner wey you configure with --disableupdate still need manual update within 30 days after new release, or GitHub go stop queuing jobs for am.

Manual update dey keep the registration, because .runner and .credentials no dey inside the tarball. Stop the service, download and verify 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, e go remain listed as Offline for the repository. E go stay there until admin click Force remove this runner, or until GitHub remove am automatically after 14 days without connection.

Wahala wey fit happen, plus the strings wey you go see

Must not run with sudo. config.sh dey print this message and exit when you run am as root. This check dey intentional, because files wey root own for _work go spoil every later job wey run as the service user. Run ./config.sh as gharunner. RUNNER_ALLOW_RUNASROOT variable dey bypass the check, but if you use am, e go only 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'. Most times, 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. Also check say --url na the complete repository URL.

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, ./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 inside the runner user's home directory, and nothing dey clean dem automatically. 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 talk say command not found?

Because svc.sh no dey inside the runner tarball. ./config.sh dey generate am for runner directory after registration finish, 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 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 go open outbound HTTPS connection to GitHub and keep am open while e dey wait for jobs, so GitHub no go ever initiate connection to your VPS. Allow outbound 443 and leave your inbound rules closed. If runner show Offline while e dey run as service, check outbound filtering and DNS instead of inbound rules.

I fit use self-hosted runner for public repository?

You fit, but GitHub advise against 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. Default approval prompt only cover contributors wey never get commit or pull request merged into the repository. 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 fail with Http response code: NotFound?

Registration call dey 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 point to repository where you get admin rights.

Dis article help you?

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