How to Deploy a GitHub Actions Self-Hosted Runner on a VPS
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Set up a GitHub Actions self-hosted runner on a small VPS — a dedicated non-root user, a checksum-verified download, a systemd service, labels, ephemeral runners and updates, with the security rules that decide whether you should do it at all.
- A VPS sized for the jobs you will run (the runner application itself needs little; builds, tests and Docker images decide the RAM and disk)
- A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access, used for nothing else
- A private GitHub repository or an organization where you can add runners, and permission to create a registration token
What a self-hosted runner is
A GitHub Actions runner is the machine that executes your workflow jobs. GitHub provides hosted ones; a self-hosted runner is a machine you own that runs GitHub's open-source runner application (MIT-licensed) and polls GitHub over outbound HTTPS for jobs. That makes it useful for builds that need more CPU, RAM or disk than a hosted runner offers, for jobs that must reach a private network, and for keeping a warm cache between runs. Because it only makes outbound connections, the server needs no inbound ports beyond SSH.
This is not an app in the catalog, so the facts here come from GitHub's own documentation, linked in the sections below, plus a verification run on a fresh Ubuntu server. It picks up from "you have a server" and ends with a checksum-verified runner install, a service definition and an update and removal routine. If you want a self-hosted CI system that is not tied to GitHub, see Woodpecker or the CI built into Gitea. For help choosing a provider and tier, see Best VPS for Docker and Best VPS for self-hosting.
Read this before you start: security
A self-hosted runner executes whatever code your workflows contain, as a real user on a real machine that you maintain. GitHub's documentation is blunt about the consequences:
- Never use a self-hosted runner for a public repository. Anyone can fork a public repository and open a pull request whose workflow runs on your machine. GitHub says self-hosted runners should "almost never" be used for public repositories, and its add-runner page recommends them only for private repositories.
- Even private repositories carry risk. Anyone who can fork or push to the
repository, generally anyone with read access, can run code on the runner,
reach its secrets and the workflow's
GITHUB_TOKEN. - Isolate the VM. Use a server that holds nothing else: no SSH keys to other machines, no cloud credentials, no databases. Check what the machine can reach on its network too; a cloud metadata service is a classic target.
- Keep token scope minimal. The registration token is short-lived (one
hour) and only adds a runner. Do not use a long-lived personal access token
where a registration token will do, and give workflows the smallest
permissions:they need. - Do not assume a persistent runner is clean. Jobs share the machine, and secrets passed as command-line arguments can be seen by other jobs running at the same time. Prefer ephemeral runners, covered below.
- Separate by trust. If you have an organization, use runner groups to restrict which repositories may use which runners.
If any of that does not fit your situation, stop and use GitHub-hosted runners.
Server sizing
GitHub's documentation says the runner application "only requires minimal resources"; what you need is whatever your jobs need. After extraction the runner directory was about 680 MB on disk in this guide's verification run, before any job work directories, caches or Docker images. A job that builds container images or runs a test suite with a database wants several GB of RAM and tens of GB of disk, so size the server for your heaviest workflow and add headroom for the work directory, which grows until you clean it.
If your jobs use Docker (container actions, service containers, image builds), the server must be Linux with Docker installed; the Docker and Compose on Ubuntu guide covers that. Read the Docker warning in the next section before you let the runner user near it.
Paid link — we earn a commission if you shop through it.
Prepare the server
Update the system and install the few tools this guide uses:
sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y curl jq ufw
If you're logged in as root, create a separate sudo user for your own administration, and continue as that user:
adduser deploy
usermod -aG sudo deploy
The runner makes only outbound connections, so open SSH and nothing else:
sudo ufw allow OpenSSH
sudo ufw --force enable
Now create the account the runner will run as. It gets no password, no sudo
rights and no SSH access. Jobs run as this user, so anything it can do, a
workflow can do:
id runner >/dev/null 2>&1 || sudo adduser --disabled-password --gecos "" runner
! sudo -u runner -H sudo -n true 2>/dev/null && echo "runner has no sudo rights"
One warning: adding this user to the docker group, which jobs that build
images need, gives it root-equivalent control of the machine. That is a
reasonable trade on an isolated, disposable VM and a bad one anywhere else.
Download and verify the runner
GitHub updates the runner often, so this guide does not hard-code a version. It asks the GitHub API for the latest release, downloads the build for your CPU, and checks it against the SHA-256 hash that GitHub publishes in the release notes:
sudo -u runner -H bash -s <<'INNER'
set -euo pipefail
mkdir -p ~/actions-runner && cd ~/actions-runner
case "$(uname -m)" in x86_64) ARCH=x64;; aarch64) ARCH=arm64;; *) ARCH=arm;; esac
REL=$(curl -fsSL https://api.github.com/repos/actions/runner/releases/latest)
VER=$(jq -r .tag_name <<<"$REL" | sed 's/^v//')
FILE="actions-runner-linux-$ARCH-$VER.tar.gz"
URL=$(jq -r --arg n "$FILE" '.assets[] | select(.name == $n) | .browser_download_url' <<<"$REL")
SHA=$(jq -r .body <<<"$REL" | grep -o "BEGIN SHA linux-$ARCH -->[0-9a-f]*" | sed 's/.*-->//')
echo "runner $VER ($ARCH)"
curl -fsSL -o "$FILE" "$URL"
echo "$SHA $FILE" | sha256sum -c -
tar xzf "$FILE" && rm "$FILE"
ls
INNER
You should see the version, OK from sha256sum, and the extracted files. The
script stops on a mismatch rather than carrying on. Be clear about what that
check proves: it catches a corrupted or truncated download, and it trusts the
release page it read the hash from. For a check against an independent copy,
compare the hash with the one GitHub's New self-hosted runner page prints
in your repository's settings, which is the second place GitHub lists it.
Then install the system libraries the runner needs. This must run with sudo,
so it runs as you rather than as the runner user:
sudo /home/runner/actions-runner/bin/installdependencies.sh 2>&1 | tail -6
The script tries several versions of the ICU library in turn, so on a new
Ubuntu release it can print Unable to locate package for the versions that
do not exist yet before it finds the one that does. That is expected. What
matters is that it ends with Finish Install Dependencies and that the next
command starts the runner.
Check that the runner application starts and shows its options, including the ones this guide uses:
sudo -u runner -H bash -c 'cd ~/actions-runner && ./bin/Runner.Listener --version && ./config.sh --help | grep -E -- "--(url|token|name|labels|ephemeral|unattended|replace|disableupdate) "'
Register the runner
Registration needs a token that only your GitHub account can create, so this is
the one step this guide could not run for you. In your repository, open
Settings, then Actions, then Runners, and choose New self-hosted
runner to get a registration token (for an organization, the same path is in
the organization's settings). The token expires after one hour. Then register,
as the runner user, with a name and labels of your choosing. --unattended
skips the interactive questions:
read -rsp 'Registration token: ' RUNNER_TOKEN; echo
export RUNNER_TOKEN
sudo -u runner -H --preserve-env=RUNNER_TOKEN bash -c 'cd ~/actions-runner && ./config.sh \
--url https://github.com/OWNER/REPO \
--token "$RUNNER_TOKEN" \
--name vps-runner-1 \
--labels vps,builder \
--unattended'
unset RUNNER_TOKEN
The token is typed at a silent prompt and passed through the environment rather
than written on the command line, so it stays out of your shell history and out
of ps output; it is also only valid for one hour.
Replace OWNER/REPO with your repository (or https://github.com/ORG for an
organization runner) and paste the token at the prompt. Every runner
automatically gets the labels self-hosted, Linux and the CPU architecture
(X64); --labels adds your own on top, so you can route jobs to specific
machines. Labels are case-insensitive.
For an ephemeral runner, which takes exactly one job and is then removed
from GitHub, add --ephemeral. GitHub recommends ephemeral runners for
anything beyond a single trusted repository, because each job then gets a
clean start if your automation wipes or recreates the machine between jobs. An
ephemeral runner only helps when something recreates it; on a single
long-lived VM it is unregistered after one job and does nothing until you
register it again. If you use ephemeral runners at
scale, also look at GitHub's just-in-time runner API and its Actions Runner
Controller for Kubernetes, which automate this.
Run it as a service
An interactive ./run.sh stops when your SSH session ends. After registration,
the runner folder contains a svc.sh script that installs a systemd service.
The service runs as the user you name, which must be the runner user, never
root:
cd /home/runner/actions-runner
sudo ./svc.sh install runner
sudo ./svc.sh start
sudo ./svc.sh status
The generated unit is based on a template the runner ships, which you can read
now (the runner's home directory is private, hence sudo). It runs the runner as the configured user, waits for the network, and
gives a running job up to five minutes to finish when the service stops:
sudo cat /home/runner/actions-runner/bin/actions.runner.service.template
On Ubuntu with needrestart enabled, a package upgrade can restart services
in the middle of a job. GitHub's documentation gives a one-line fix that tells
needrestart to leave the runner service alone:
echo '$nrconf{override_rc}{qr(^actions\.runner\..+\.service$)} = 0;' | sudo tee /etc/needrestart/conf.d/actions_runner_services.conf
Once the service runs, the runner shows as Idle under Settings, Actions, Runners. Target it from a workflow with its labels, for example:
jobs:
build:
runs-on: [self-hosted, linux, x64, builder]
steps:
- uses: actions/checkout@v4
- run: echo "running on my own server"
What was NOT tested
Be honest about the edges. The download, checksum verification, dependency
installation, the dedicated user and the config.sh --help output were run on
a fresh Ubuntu server for this guide. Registration (including the silent
token prompt and --preserve-env hand-off), removal, the systemd service
created by svc.sh, and a real workflow job were not run, because they need
a GitHub registration token and a repository that this verification run did
not have. Those steps follow GitHub's documentation, so test them on a
disposable machine and a scratch private repository before you rely on them.
Updates
By default, a self-hosted runner updates itself: when GitHub releases a newer
runner version, the application downloads it and restarts. That is usually what
you want for a long-lived runner. Two cases differ. A runner started from a
container image, or an ephemeral runner recreated from an image for every job,
would re-download the update every time, so pin the version in the image and
register with --disableupdate, then rebuild the image on your schedule. And
if you disable updates you must still update the runner yourself: GitHub's
documentation says to do so within 30 days of a release, after which the
service stops queuing jobs to that runner.
Update the operating system as for any server: sudo apt-get update && sudo apt-get -y upgrade, with the needrestart setting above so the runner is not
restarted mid-job.
Removing a runner
To retire a runner cleanly, stop and uninstall the service, then unregister it. A fresh removal token comes from the same Runners page:
cd /home/runner/actions-runner
sudo ./svc.sh stop
sudo ./svc.sh uninstall
read -rsp 'Removal token: ' RUNNER_TOKEN; echo
export RUNNER_TOKEN
sudo -u runner -H --preserve-env=RUNNER_TOKEN bash -c 'cd ~/actions-runner && ./config.sh remove --token "$RUNNER_TOKEN"'
unset RUNNER_TOKEN
As with registration, the token is read silently so it never lands in history or
ps output.
GitHub also removes a runner on its own if it has not connected for more than 14 days (one day for an ephemeral runner). If the machine is gone and you cannot run the command, use Force remove this runner in the settings page.
Troubleshooting
The runner shows Offline. Either the machine is off, the service is not
running, or the runner cannot reach GitHub. Check
sudo ./svc.sh status in the runner folder, then the logs with
journalctl -u 'actions.runner.*' -n 50. GitHub's runner application has a
connectivity check (--check with a personal access token that has the
workflow scope); its documentation shows it on config.sh and this runner
version's help text lists it on run.sh, so run --help and use whichever
your version shows.
A job stays Queued. No online, idle runner matches the job's runs-on
labels. Labels are matched exactly, so check spelling and the default
self-hosted, Linux, X64 set. GitHub fails a job that waits more than 24
hours.
Jobs fail with "No space left on device". The _work folder and Docker
images grow without limit. Clean the work directory, run
sudo docker system prune if jobs build images, and consider ephemeral
runners that start clean.
A job dies when you upgrade packages. needrestart restarted the runner
service. Apply the override shown in the service section.
The runner "needs to be configured". ./run.sh and svc.sh install only
work after a successful config.sh. Registration tokens expire after one
hour, so generate a new one and register again with --replace if a runner
with that name already exists.
Verification + next steps
You're done when the runner shows Idle in your repository's settings, a
workflow with a matching runs-on label completes on it, the service comes
back after a reboot, and a test pull request from a fork does not run on
it (it must not, if your repository is private and your fork settings are
tight). Re-read the security section after each change to who has access to the
repository.
From there, add a second runner on a separate VM, move to ephemeral runners if your jobs touch anything sensitive, and keep a short list of the repositories allowed to use each runner. GitHub's pages worth keeping open are its add runners, run as a service and secure use documents.