Who this is for
Teams with private repositories whose builds are slowed by cold caches, or who need a toolchain the hosted images don't carry. It is not for open-source projects that accept pull requests from strangers; that case is covered further down. If you want the cost and sizing argument first, read the CI runners overview.
You need a FastNode server running the Ubuntu 24.04 image, SSH access as a sudo user, and admin rights on the GitHub repository or organisation.
Step 1: a dedicated user
The runner refuses to configure as root, and you would not want it to. Give it a locked account with no password and no sudo:
sudo adduser --disabled-password --gecos "" gha
sudo mkdir -p /srv/ci-cache && sudo chown gha:gha /srv/ci-cache
Anything this user can read, a workflow can read. Keep deploy keys and other projects off this box.
Step 2: download and verify the tarball
Open the repository (or organisation) and go to Settings, Actions, Runners, New self-hosted runner, Linux, x64. That page shows the current version number and its SHA-256. Copy both into the variables below rather than trusting a version written in any guide, including this one.
sudo -iu gha
RUNNER_VERSION="x.y.z" # from the Runners page
RUNNER_SHA256="paste-hash-here" # from the same page
mkdir actions-runner && cd actions-runner
curl -fsSLo runner.tar.gz \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
echo "${RUNNER_SHA256} runner.tar.gz" | sha256sum -c -
tar xzf runner.tar.gz && rm runner.tar.gz
exit
sha256sum -c must print runner.tar.gz: OK. If it doesn't, stop and download again. The runner is a .NET application and needs ICU and a few libraries; the bundled script installs them on Ubuntu 24.04:
sudo /home/gha/actions-runner/bin/installdependencies.sh
Step 3: register with config.sh
The same settings page shows a registration token that expires after about an hour. Use it straight away:
sudo -iu gha
cd actions-runner
./config.sh --unattended \
--url https://github.com/example-org/example-repo \
--token AXXXXXXXXXXXXXXXXXXXXXXXXXXXX \
--name lon-gha-01 \
--labels london,nvme,docker \
--work _work
exit
Every self-hosted runner automatically gets the self-hosted, Linux and X64 labels. Yours are added on top. Label by hardware and location, not project, so workloads can move without editing every workflow.
Step 4: install it as a service
cd /home/gha/actions-runner
sudo ./svc.sh install gha
sudo ./svc.sh start
sudo ./svc.sh status
The argument to install is the user the service runs as. This writes a unit called actions.runner.example-org-example-repo.lon-gha-01.service, enables it at boot, and the runner should appear as Idle on the Runners page within a few seconds. Logs land in _diag/.
How do I send jobs to it from a workflow?
Set runs-on to a list of labels. GitHub assigns the job only to a runner that carries every label in the list. Including self-hosted plus one of your own labels is the safest pattern, because it can never accidentally match a hosted runner and it still lets you add more machines later.
jobs:
build:
runs-on: [self-hosted, london, nvme]
steps:
- uses: actions/checkout@v7
- run: make test
Is it safe to give the runner Docker access?
Only if you accept that the runner user becomes root-equivalent. Membership of the docker group lets any job start a privileged container with the host filesystem mounted, which is a full root shell. That is acceptable on a single-purpose box running your own team's code, and nowhere else.
sudo usermod -aG docker gha
sudo ./svc.sh stop && sudo ./svc.sh start
Restart the service after adding the group, or jobs won't see it. If that trade is too much, run rootless Docker as the gha user instead. Builds still work and a break-out lands as an unprivileged user.
Why not use it on a public repository?
Anyone can fork a public repository and open a pull request whose workflow runs on your runner. On a persistent machine that code can read caches, plant a backdoor for the next job, or mine on your CPUs. GitHub's own guidance is to use self-hosted runners only with private repositories, and you should follow it.
If you must, set the repository to require approval for all outside collaborators' workflow runs, and use ephemeral mode. At organisation level, put the runner in a group that lists only the repositories allowed to use it.
Ephemeral runners
With --ephemeral, the runner takes exactly one job, then deregisters and exits. The next job gets a freshly registered runner and an empty work directory. You lose the checkout and tool cache but keep anything outside the runner directory, such as Docker's image store and /srv/ci-cache.
Because each registration consumes a token, ephemeral mode needs a token source. A small wrapper calls the GitHub REST API with a fine-grained token with repository Administration write permission, then registers and runs:
#!/bin/sh
# /home/gha/run-ephemeral.sh
set -e
cd /home/gha/actions-runner
TOKEN=$(curl -fsS -X POST \
-H "Authorization: Bearer $(cat /home/gha/.gh-pat)" \
https://api.github.com/repos/example-org/example-repo/actions/runners/registration-token \
| sed -n 's/.*"token": *"\([^"]*\)".*/\1/p')
./config.sh --unattended --ephemeral --replace \
--url https://github.com/example-org/example-repo \
--token "$TOKEN" --name lon-gha-01 --labels london,nvme
exec ./run.sh
Run it from a systemd unit with Restart=always and User=gha instead of using svc.sh. Keep .gh-pat at mode 600; a job running as gha can still read it, which is why ephemeral mode limits damage rather than removing the need for trust.
Put caches on NVMe, outside the work tree
The runner reads a .env file in its install directory and passes the variables to every job. Point the common package caches at a stable path so they survive work-directory cleanups and ephemeral re-registration:
# /home/gha/actions-runner/.env
npm_config_cache=/srv/ci-cache/npm
PIP_CACHE_DIR=/srv/ci-cache/pip
GOMODCACHE=/srv/ci-cache/go/mod
GOCACHE=/srv/ci-cache/go/build
CARGO_HOME=/srv/ci-cache/cargo
RUNNER_TOOL_CACHE=/srv/ci-cache/toolcache
Restart the service after editing. RUNNER_TOOL_CACHE is where setup-node, setup-python and friends unpack toolchains, so the second job skips that download entirely.
A cleanup timer
Old work directories and build cache will fill a 40 GB disk within weeks. A nightly systemd timer keeps it in check:
# /etc/systemd/system/gha-cleanup.service
[Unit]
Description=Trim GitHub runner caches
[Service]
Type=oneshot
ExecStart=/usr/bin/find /home/gha/actions-runner/_work/_temp -mindepth 1 -mtime +1 -delete
ExecStart=/usr/bin/find /srv/ci-cache -type f -atime +21 -delete
ExecStart=/usr/bin/docker builder prune -f --filter until=72h
# /etc/systemd/system/gha-cleanup.timer
[Timer]
OnCalendar=*-*-* 03:30
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now gha-cleanup.timer
Drop the Docker line if the runner has no Docker access.
Networking: outbound only
The runner opens connections to GitHub; GitHub never connects in. In the portal's per-VM firewall, deny all inbound traffic and allow TCP 22 from your office or VPN address. Traffic is unmetered on the 1 Gbps port, so large dependency downloads and artefact uploads cost nothing extra.
Which plan should I pick for a runner?
Size for the heaviest job times the number you want running at once. One runner process handles one job, so concurrency means several registered runners on the same server, each in its own directory with its own service. RAM runs out before CPU for most JavaScript and JVM builds.
- Boost (2 vCPU, 4 GB, 40 GB, $8/mo): one runner, tests and small compiled projects.
- Turbo (4 vCPU, 8 GB, 80 GB, $13/mo): two runners, or one doing Docker image builds with room for layer cache.
- Nitro (4 vCPU, 12 GB, 120 GB, $15/mo): two runners on memory-hungry builds, or integration tests that start a database per job.
Prices are charged in US dollars. Take a snapshot from the portal once the runner is registered and tuned, so a broken host is a restore and a fresh token away from working again.