Skip to content

GitHub Actions Self-Hosted Runners on Linux: Setup and Security

Install a GitHub Actions runner on Linux as a systemd service, then lock it down: dedicated user, ephemeral jobs, runner groups, egress rules and public repo risks.

●By 11 min read

GitHub-hosted runners are disposable VMs: every job gets a fresh machine that is destroyed afterwards. A self-hosted runner is the opposite. It is your Linux box, with your network access, your caches and whatever the previous job left behind. That is why teams use them, for GPUs, private networks, big build caches or cheaper minutes, and it is also why they are one of the easiest ways to turn a pull request into a foothold inside your infrastructure.

This guide installs a runner properly, as a systemd service under its own user, and then works through the decisions that actually decide its security: who can send it jobs, whether it is reused between jobs, and what it can reach.

Install the Runner Under a Dedicated User

The runner is a .NET application that GitHub ships as a tarball. It must not run as root: config.sh refuses unless you set RUNNER_ALLOW_RUNASROOT, and you should not. Create a system user with no password and no sudo:

# Debian/Ubuntu

$ sudo adduser --disabled-password --gecos "" ghrunner

# RHEL/Fedora

$ sudo useradd --create-home --shell /bin/bash ghrunner

$ sudo -iu ghrunner

$ mkdir actions-runner && cd actions-runner

# Copy the current version and SHA-256 from Settings > Actions > Runners > New self-hosted runner

$ RUNNER_VERSION=2.328.0

$ curl -fsSLO https://github.com/actions/runner/releases/download/v$RUNNER_VERSION/actions-runner-linux-x64-$RUNNER_VERSION.tar.gz

$ echo "<sha256-from-the-release-page> actions-runner-linux-x64-$RUNNER_VERSION.tar.gz" | sha256sum -c

actions-runner-linux-x64-2.328.0.tar.gz: OK

$ tar xzf actions-runner-linux-x64-$RUNNER_VERSION.tar.gz

# Missing libicu or similar? The bundled script installs them (needs a sudo-capable account)

$ exit

$ sudo /home/ghrunner/actions-runner/bin/installdependencies.sh

Registration needs a short-lived token. The web UI shows one, but for repeatable setups fetch it from the API with the GitHub CLI. Registration tokens expire after one hour and are only used once, at configure time:

# Repository-level runner

$ gh api -X POST repos/acme/webapp/actions/runners/registration-token --jq .token

# Organization-level runner (preferred, see runner groups below)

$ gh api -X POST orgs/acme/actions/runners/registration-token --jq .token

$ sudo -iu ghrunner

$ cd actions-runner

$ ./config.sh --url https://github.com/acme \

--token AABCD...EXAMPLE \

--name build-01 \

--labels linux-build,ssd \

--runnergroup internal-builds \

--work _work \

--unattended

config.sh flagPurpose
--urlRepository, organization or enterprise URL the runner registers to
--labelsExtra labels matched by runs-on; self-hosted, Linux and X64 are added automatically
--runnergroupOrganization runner group, which controls which repositories may use it
--ephemeralTake exactly one job, then unregister
--replaceReplace an existing runner with the same name instead of failing
--disableupdateStop self-updates; you then own upgrades, and outdated runners stop receiving jobs
--unattendedNo prompts, for scripts and image builds

Run It as a systemd Service

./run.sh runs the runner in the foreground, which is fine for a test. For a real host, the bundled svc.sh writes a systemd unit that starts at boot and runs as the user you name:

$ cd /home/ghrunner/actions-runner

$ sudo ./svc.sh install ghrunner

$ sudo ./svc.sh start

$ sudo ./svc.sh status

● actions.runner.acme.build-01.service - GitHub Actions Runner (acme.build-01)

Loaded: loaded (/etc/systemd/system/actions.runner.acme.build-01.service; enabled)

Active: active (running) since Fri 2026-10-02 10:14:03 UTC; 6s ago

# Logs: systemd journal plus detailed files in _diag/

$ journalctl -u actions.runner.acme.build-01 -f

$ ls /home/ghrunner/actions-runner/_diag/

Runner_20261002-101403-utc.log Worker_20261002-101921-utc.log

The generated unit is minimal. Add a drop-in rather than editing it, so svc.sh can reinstall it later. These settings stop jobs from gaining privileges or writing outside the runner directory without breaking normal builds:

$ sudo systemctl edit actions.runner.acme.build-01.service

[Service]

NoNewPrivileges=yes

PrivateTmp=yes

ProtectSystem=full

ProtectKernelTunables=yes

ProtectKernelModules=yes

ProtectControlGroups=yes

RestrictSUIDSGID=yes

$ sudo systemctl restart actions.runner.acme.build-01.service

NoNewPrivileges blocks sudo β€” and that is the point. If a workflow needs sudo, it needs a different kind of runner, ideally a throwaway VM, not a password-less sudo rule on a long-lived host.

ProtectSystem=full β€” makes /usr, /boot and /etc read-only for the runner and its jobs. strict goes further and makes everything read-only, which then needs ReadWritePaths= for the work directory and any caches.

Environment for jobs β€” the runner reads .env and .path in its directory at start. Put proxy settings or tool paths there instead of in the user's shell profile, which services do not load.

Build your own unit β€” if you prefer to manage the unit yourself, our systemd generator produces one with hardening options; point ExecStart at runsvc.sh in the runner directory.

Who Can Send Jobs to Your Runner

A runner executes whatever a workflow file says. Anyone who can get a workflow to run on it has code execution as the runner user on your network. GitHub's own guidance is blunt: do not attach self-hosted runners to public repositories, because a fork can open a pull request that changes the workflow and runs on your machine.

RiskMitigation
Fork pull requests on a public repoUse GitHub-hosted runners for public repos; if you must, require approval for all outside collaborators in Settings > Actions
Any repo in the org can target the runnerPut runners in a runner group limited to selected repositories, and disable the Default group for them
pull_request_target workflowsThey run with secrets in the base repo context; never check out and execute PR code in them
Overly broad GITHUB_TOKENSet permissions: contents: read at the top of every workflow and widen per job
Label collisionsUse specific labels (linux-build, gpu-a100) so jobs land only where intended

# .github/workflows/build.yml

name: build

on:

push:

branches: [main]

pull_request:

permissions:

contents: read

jobs:

build:

runs-on: [self-hosted, linux, linux-build]

timeout-minutes: 30

steps:

- uses: actions/checkout@v4

with:

persist-credentials: false

- run: make test

persist-credentials: false β€” by default actions/checkout leaves the token in .git/config inside the work directory. On a reused runner, the next job can read it while it is still valid.

timeout-minutes β€” the default job timeout is 360 minutes. A hung build on a self-hosted runner blocks every queued job behind it, since one runner process takes one job at a time.

Pin third-party actions β€” reference actions by full commit SHA rather than a tag. A compromised tag on a popular action runs on your hardware, not GitHub's.

sponsored

Persistent vs Ephemeral Runners

A persistent runner keeps its work directory, tool caches, Docker images and any background process a job started. That makes it fast and also means job N can tamper with job N+1: poison a cache, leave a modified binary in the PATH, or wait for the next job's secrets. Ephemeral runners take one job and exit.

# Simple form: one job, then the runner unregisters and run.sh exits

$ ./config.sh --url https://github.com/acme --token AABCD...EXAMPLE \

--ephemeral --unattended --labels linux-build --runnergroup internal-builds

$ ./run.sh

# Better: just-in-time config, no registration step on the host at all

$ gh api -X POST orgs/acme/actions/runners/generate-jitconfig \

-f name="build-$(date +%s)" -F runner_group_id=3 \

-f "labels[]=self-hosted" -f "labels[]=linux-build" \

--jq .encoded_jit_config > jit.txt

$ ./run.sh --jitconfig "$(cat jit.txt)"

Ephemeral only helps if the host is fresh too β€” a runner that exits after one job on the same long-lived VM still leaves files behind. Pair --ephemeral with a new VM or container per job, created by your orchestration and destroyed afterwards.

Kubernetes β€” Actions Runner Controller (ARC) runs ephemeral runner pods that scale with the job queue. It is GitHub's supported option for running many runners on a cluster.

Cleanup hooks for persistent runners β€” if you cannot go ephemeral, set ACTIONS_RUNNER_HOOK_JOB_STARTED and ACTIONS_RUNNER_HOOK_JOB_COMPLETED in the runner's .env to scripts that wipe the work directory, prune Docker and kill stray processes.

Docker, Network and Secrets on the Host

Workflows that use container: jobs or service containers need Docker on the runner, and the usual shortcut is to add the runner user to the docker group. That group is root-equivalent: any job can run docker run -v /:/host and own the machine. Treat a runner with Docker access as a root-level runner and isolate it accordingly, or use rootless Docker for the runner user. Our Docker for Linux admins guide covers the socket and group model.

# The runner only needs outbound HTTPS; it long-polls GitHub, nothing connects in

# Example nftables policy for the runner user (uid 990): internal networks

# only on 443, and no access to the cloud metadata endpoint

table inet runner_egress {

chain output {

type filter hook output priority 0; policy accept;

meta skuid 990 ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } tcp dport != 443 drop

meta skuid 990 ip daddr 169.254.169.254 drop

}

}

No inbound ports β€” the runner opens outbound connections to github.com, api.github.com and GitHub's actions and artifact endpoints on 443. Close everything inbound except your management SSH.

Block cloud metadata β€” on a cloud VM, 169.254.169.254 hands out instance credentials. A malicious job that reaches it inherits whatever IAM role the runner VM has.

Restrict lateral movement β€” the runner sits inside your network. Limit what the runner user can reach to the package mirrors, registries and deploy targets it needs. Our firewall rules generator builds iptables, nftables and UFW rule sets.

Prefer OIDC to stored cloud keys β€” cloud providers accept GitHub's OIDC token for short-lived credentials, so no long-lived access key needs to live in secrets or on the runner disk.

Keep it patched β€” a runner host is a server like any other. Our Linux server hardening guide covers unattended upgrades, SSH and audit logging.

To retire a runner cleanly, stop and uninstall the service, then deregister it with a removal token so it does not linger as offline in the UI:

$ sudo ./svc.sh stop && sudo ./svc.sh uninstall

$ TOKEN=$(gh api -X POST orgs/acme/actions/runners/remove-token --jq .token)

$ sudo -u ghrunner ./config.sh remove --token "$TOKEN"

Frequently Asked Questions

Is it safe to use a self-hosted runner with a public repository?

GitHub recommends against it. Anyone can fork a public repository and open a pull request that modifies the workflow, and if that workflow runs on your self-hosted runner, they execute code on your machine and network. Use GitHub-hosted runners for public repositories, or at minimum require approval for all outside contributors and use ephemeral, isolated runners.

Can a GitHub Actions runner run as root?

config.sh refuses to run as root unless the RUNNER_ALLOW_RUNASROOT environment variable is set, and you should not set it. Create a dedicated unprivileged user without sudo, install the runner in its home directory, and run svc.sh install with that user name so the systemd service runs as it.

What is an ephemeral self-hosted runner?

A runner registered with --ephemeral, or started with a just-in-time config, accepts exactly one job and then unregisters itself. Combined with a fresh VM or container per job, it prevents one job from leaving files, processes or cached credentials behind for the next one.

Does a self-hosted runner need inbound firewall ports?

No. The runner makes outbound HTTPS connections to GitHub on port 443 and long-polls for jobs. Nothing on the internet needs to connect to it, so you can block all inbound traffic except your own management access.

How do I see why a self-hosted runner job failed to start?

Check the service journal with journalctl -u followed by the actions.runner unit name, and read the Runner_ and Worker_ log files in the _diag directory inside the runner folder. Also confirm in the repository or organization settings that the runner is online and that its labels match the runs-on line of the job.

sponsored

Related Tools

See also