Skip to content

How to Deploy a GitHub Actions Self-Hosted Runner on a VPS

Updated Oct 2026

verified on Ubuntu 26.04 · Oct 2026
We earn commissions when you shop through the links below. Full disclosure →

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

Before you start
  • 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
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

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.

Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

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.

Next steps

Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AFFiNE on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy copyparty on a VPS →How to Deploy Directus on a VPS →How to Deploy Discourse on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Flarum on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghost on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Infisical on a VPS →How to Deploy Invoice Ninja on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Kanboard on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy a Minecraft Server on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Odoo on a VPS →How to Deploy Ollama on a VPS →How to Deploy ONLYOFFICE Docs on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Plex Media Server on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy WordPress on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.