$linuxjunkies
>

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.

IntermediateUbuntuDebianFedoraArch9 min readUpdated June 7, 2026

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 processnever run as root.
  • curl, tar, and jq installed 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 like libicu — install it with sudo apt-get install -y libicu-dev or sudo 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 to ReadWritePaths= in the systemd override, or use PrivateTmp=yes which provides an isolated /tmp automatically.
  • Ephemeral runner doesn't re-register: The svc.sh helper only installs a persistent service. For ephemeral runners, use a ExecStopPost= directive in the unit file that calls a re-registration script, or adopt the actions-runner-controller for Kubernetes-based scaling.
tested on:Ubuntu 24.04Debian 12Fedora 40Rocky 9

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