Self-Host a GitHub Actions Runner on Linux
Install a GitHub Actions self-hosted runner on Linux, configure it as a systemd service, assign labels, register at org level, and harden the execution environment.
Before you start
- ▸Admin access to a GitHub repository or organisation
- ▸A Linux server with at least 2 vCPUs and 4 GB RAM
- ▸curl, tar, and jq installed on the target host
- ▸SSH access and sudo privileges on the server
GitHub's hosted runners are convenient, but self-hosted runners give you faster I/O, access to private network resources, custom toolchains, and full control over the execution environment. This guide walks through installing a GitHub Actions self-hosted runner on Linux, wiring it up as a systemd service, tagging it with labels, registering at the organisation level, and locking it down so a compromised workflow can't own your host.
Prerequisites
- A Linux server (VM or bare metal) with at least 2 vCPUs and 4 GB RAM for comfortable use.
- A GitHub account with admin access to a repository or organisation.
- A dedicated, non-root user account for the runner process — never run as root.
curl,tar, andjqinstalled on the server.
Create a Dedicated Runner User
Isolate the runner process with a system account that has no login shell and no sudo rights.
sudo useradd --system --create-home --shell /usr/sbin/nologin ghrunner
Switch to that user's home directory for all subsequent installation steps.
sudo -u ghrunner mkdir -p /home/ghrunner/actions-runner
cd /home/ghrunner/actions-runner
Download and Verify the Runner Package
GitHub publishes runner releases at github.com/actions/runner/releases. Always fetch the latest stable release and verify its checksum. Substitute the version number as needed.
RUNNER_VERSION="2.317.0"
curl -fsSL -o actions-runner-linux-x64.tar.gz \
"https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz"
# Verify the SHA-256 checksum (get the expected hash from the release page)
sha256sum actions-runner-linux-x64.tar.gz
sudo -u ghrunner tar xzf actions-runner-linux-x64.tar.gz -C /home/ghrunner/actions-runner
Register the Runner with GitHub
Generate a Registration Token
Tokens expire after one hour. Generate one immediately before running the config script.
Repository runner — navigate to Settings → Actions → Runners → New self-hosted runner in your repo and copy the token shown there.
Organisation runner — navigate to your organisation's Settings → Actions → Runners → New runner. Organisation runners can serve multiple repositories and are the better choice for teams.
You can also retrieve a token via the API if you're automating provisioning:
# For a repo runner (replace ORG and REPO)
GH_TOKEN="ghp_yourPersonalAccessToken"
curl -fsSL -X POST \
-H "Authorization: token ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/ORG/REPO/actions/runners/registration-token" \
| jq -r '.token'
Run the Configuration Script
The --labels flag is how you target this runner from workflow YAML. Add meaningful labels: hardware class, OS, available tools, environment name. Labels are comma-separated and case-insensitive.
sudo -u ghrunner /home/ghrunner/actions-runner/config.sh \
--url https://github.com/YOUR_ORG_OR_USER/YOUR_REPO \
--token PASTE_TOKEN_HERE \
--name "prod-runner-01" \
--labels "self-hosted,linux,x64,ubuntu-24.04,docker" \
--work "_work" \
--unattended
For an organisation-level runner, set --url https://github.com/YOUR_ORG and optionally scope it to specific repos with --runnergroup once runner groups are configured in your org settings.
Install as a systemd Service
The runner ships with a helper script that writes a systemd unit file, but it targets root by default. The safer approach is to install the service explicitly for the ghrunner account.
sudo /home/ghrunner/actions-runner/svc.sh install ghrunner
Inspect the generated unit file before enabling it:
cat /etc/systemd/system/actions.runner.*.service
The unit should show User=ghrunner. If it doesn't, edit it before proceeding. Then reload and start:
sudo systemctl daemon-reload
sudo systemctl enable --now actions.runner.*.service
Harden the Runner
A self-hosted runner that executes arbitrary workflow code is a meaningful attack surface. Apply these controls before accepting pull-request workflows from external contributors.
Restrict Who Can Trigger Workflows
In your repository or organisation settings, set Actions → Fork pull request workflows from outside collaborators to Require approval for all outside collaborators. This prevents a malicious fork PR from executing code on your runner without a maintainer approving the run.
Harden the systemd Unit
Add security directives to the generated unit file. Edit it with:
sudo systemctl edit actions.runner.*.service
Add the following in the override file:
[Service]
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/ghrunner/actions-runner
ProtectKernelTunables=yes
ProtectKernelModules=yes
RestrictNamespaces=yes
SystemCallFilter=@system-service
Reload after saving:
sudo systemctl daemon-reload
sudo systemctl restart actions.runner.*.service
Firewall: Outbound Only
Runners only need outbound HTTPS to GitHub's API and to download artifacts. Block all inbound connections. Examples for the three common tools:
# ufw (Debian/Ubuntu)
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw enable
# firewalld (Fedora/RHEL/Rocky)
sudo firewall-cmd --set-default-zone=drop
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --reload
Use Ephemeral Runners for Sensitive Workloads
Pass --ephemeral to config.sh to make the runner deregister itself after a single job. Combined with a process supervisor that re-registers after each run (or a Kubernetes-based autoscaler), this prevents job-to-job environment contamination. The flag is available in runner version 2.293.0 and later.
Limit Docker Socket Exposure
If your workflows use Docker, avoid mounting /var/run/docker.sock directly into build containers — that grants container escape to root on the host. Use rootless Docker or Podman instead:
# Install rootless Docker for ghrunner (Ubuntu/Debian)
sudo apt-get install -y uidmap dbus-user-session
sudo -u ghrunner dockerd-rootless-setuptool.sh install
# Enable the rootless Docker socket for ghrunner's systemd session
sudo loginctl enable-linger ghrunner
Verify the Runner is Online
Check the service status and confirm GitHub sees it as idle:
sudo systemctl status actions.runner.*.service
Expected output will show Active: active (running). Then verify in the GitHub UI under Settings → Actions → Runners — the runner should appear as Idle with the labels you configured.
Trigger a test workflow that targets your runner by label:
# .github/workflows/test-runner.yml
# runs-on: [self-hosted, linux, x64, ubuntu-24.04]
Troubleshooting
- Runner shows offline immediately after start: Check
journalctl -u actions.runner.*.service -n 50. A common cause is a missing dependency likelibicu— install it withsudo apt-get install -y libicu-devorsudo dnf install -y libicu. - Registration fails with "invalid token": Tokens expire after 60 minutes. Generate a fresh one and re-run
config.sh. You do not need to re-download the runner package. - Jobs queue but never start: Confirm the label in your workflow YAML exactly matches what was configured. Labels are case-insensitive but must all be present on the runner. Check for extra whitespace.
- ProtectSystem=strict breaks builds: Workflows that write outside
/home/ghrunner/actions-runner(e.g., to/tmp) will fail. Add those paths toReadWritePaths=in the systemd override, or usePrivateTmp=yeswhich provides an isolated/tmpautomatically. - Ephemeral runner doesn't re-register: The
svc.shhelper only installs a persistent service. For ephemeral runners, use aExecStopPost=directive in the unit file that calls a re-registration script, or adopt the actions-runner-controller for Kubernetes-based scaling.
Frequently asked questions
- Can I run multiple runners on the same host?
- Yes. Create separate user accounts and installation directories for each runner, then register each with a unique --name. Each gets its own systemd service unit.
- How do I update the runner to a new version?
- GitHub will prompt you in the UI when a runner is outdated. Stop the service, download the new tarball into the same directory, extract it (it overwrites only the binaries), and restart the service. Your .credentials and .runner config files are preserved.
- Is it safe to use self-hosted runners for public repositories?
- It is risky. Anyone can open a pull request against a public repo and potentially trigger workflows on your runner. Use ephemeral runners, require approval for external contributors, and treat the runner host as untrusted compute.
- What is the difference between a repository runner and an organisation runner?
- A repository runner only accepts jobs from that single repository. An organisation runner can be shared across all or selected repos in the org, which is more efficient for teams and avoids duplicating infrastructure.
- Do I need to open any inbound ports for the runner to work?
- No. The runner uses long-polling over outbound HTTPS to GitHub's API endpoints. You only need outbound port 443 allowed, which is usually the default.
Related guides
Configure Prometheus Alertmanager
Configure Prometheus Alertmanager with routing trees, receivers, inhibition rules, grouping, Go templates, and PagerDuty/Slack on-call integrations.
Build an Intranet Server on Linux
Set up a complete small-office intranet on one Linux box: Nginx web server, dnsmasq local DNS, Samba file sharing, and a Wiki.js team wiki.
Build an nftables Firewall Script
Build a complete nftables firewall from scratch: tables, chains, sets, default-deny input policy, service allowlisting, and persistent systemd configuration.
Caddy as a Reverse Proxy
Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.