Self-Hosted Runners Security Review Pack
Intro: A self-hosted runner is a controlled remote code execution surface. The repository decides the job; the runner host pays the price. This page turns runner review into a repeatable Product Security assessment for GitHub Actions and GitLab CI.
What this page includes
- why self-hosted runners are high-value review targets;
- GitHub and GitLab specific hardening guidance;
- ephemeral versus persistent tradeoffs;
- network, identity, executor, and secret review questions;
- practical config snippets and review checklists.
Why self-hosted runners matter
When a workflow or pipeline job runs on a self-hosted runner, repository-defined code can often:
- read runner-local files or caches;
- access cloud credentials or internal networks;
- reuse artifacts from prior jobs;
- attempt persistence on the host;
- pivot into nearby systems.
What is different from hosted runners
Hosted model
- ephemeral execution is the default service pattern;
- the platform vendor owns patching and base image lifecycle;
- blast radius tends to be more contained.
Self-hosted model
- you own host hardening and updates;
- you own job isolation and cleanup;
- you own secrets exposure boundaries;
- you own network segmentation;
- you own the consequences of persistence.
Core design principles
| Principle | Practical meaning |
|---|---|
| ephemeral first | assume each runner should process one job or a very narrow trust set |
| no public trust mixing | never let untrusted pull-request code share a persistent privileged runner |
| separate by trust tier | build, test, release, and deploy should not all land on the same fleet |
| narrow network reach | most runners do not need production or internal admin-plane access |
| short-lived credentials | prefer OIDC / workload identity or tightly scoped short-lived tokens |
| observable cleanup | logs, cleanup, and evidence must survive the runner instance |
GitHub Actions review guidance
Current strongest pattern
Use ephemeral self-hosted runners where possible.
Registration example โ ephemeral runner
./config.sh \
--url https://github.com/example-org \
--token "$RUNNER_TOKEN" \
--ephemeral \
--labels linux,x64,build,isolated
When disabling auto-update
./config.sh \
--url https://github.com/example-org \
--token "$RUNNER_TOKEN" \
--ephemeral \
--disableupdate
GitHub runner hardening checklist
- prefer ephemeral runners over persistent ones;
- do not use self-hosted runners for public repos unless containment is unusually strong;
- keep runners out of production networks unless the job type absolutely requires it;
- use OIDC to cloud where possible instead of static long-lived secrets;
- keep environment secrets behind review gates where supported;
- forward runner logs externally before runner destruction;
- route jobs by labels and groups, not by hope.
Example GitHub Actions workflow routed to a dedicated runner group
name: release
on:
push:
tags:
- 'v*'
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: [self-hosted, linux, x64, prod-deploy]
environment: production
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-prod-deploy
aws-region: us-east-1
- run: ./scripts/deploy.sh
GitLab self-managed runner review guidance
Executor choices
Shell executor
Use only for highly trusted jobs on tightly controlled hosts.
Docker executor
Usually safer than shell when not privileged, with careful image and volume policy.
Kubernetes executor
Good for ephemeral isolation when namespaces, service accounts, node placement, and egress are handled correctly.
VM-based isolation
Best for highest-trust or privilege-heavy jobs when ephemeral rebuild is possible.
GitLab runner config.toml โ safer Docker baseline
concurrent = 4
check_interval = 0
[[runners]]
name = "ci-build-isolated"
url = "https://gitlab.example.com"
token = "REDACTED"
executor = "docker"
[runners.docker]
image = "alpine:3.20"
privileged = false
pull_policy = "always"
disable_cache = true
volumes = ["/cache"]
shm_size = 0
GitLab runner config.toml โ privileged exception pattern
[[runners]]
name = "image-builder-privileged"
url = "https://gitlab.example.com"
token = "REDACTED"
executor = "docker"
[runners.docker]
image = "docker:27"
privileged = true
pull_policy = "always"
If you must do this:
- isolate the runner onto dedicated ephemeral nodes or VMs;
- route only protected jobs to it;
- do not reuse it for general CI.
GitLab Kubernetes executor example
[[runners]]
name = "k8s-release"
url = "https://gitlab.example.com"
token = "REDACTED"
executor = "kubernetes"
[runners.kubernetes]
image = "alpine:3.20"
namespace = "gitlab-runners-release"
service_account = "gitlab-runner-release"
privileged = false
pull_policy = "always"
cpu_limit = "1000m"
memory_limit = "1Gi"
Network segmentation review
For both GitHub and GitLab, ask:
- can the runner reach cloud instance metadata endpoints?
- can it reach production databases or cluster control planes?
- can low-trust jobs reach internal package mirrors or secret stores?
- can one runner talk laterally to another runner subnet?
AWS IMDS blocking example with iptables
sudo iptables -A OUTPUT -d 169.254.169.254 -j REJECT
Secrets and identity
Better patterns
- GitHub Actions OIDC to AWS/Azure/GCP;
- GitLab OIDC / workload federation where available;
- environment-scoped secrets only for trusted refs;
- separate deploy identity from build identity.
Worse patterns
- long-lived cloud admin keys stored on the runner host;
- broad vault tokens reused for all jobs;
- single shared SSH key for deployment from every runner.
Persistence and cleanup
Post-job cleanup example
#!/usr/bin/env bash
set -euo pipefail
rm -rf "$RUNNER_WORKDIR/_work"/*
docker system prune -af || true
Cleanup scripts do not replace ephemeral design. They only reduce residue when persistence exists.
Top 10 runner security issues
| Issue | Why it matters | Fix |
|---|---|---|
| shared persistent runners | cross-job contamination | ephemeral or trust-tiered isolation |
| shell executor on mixed-trust repos | host takeover risk | use container or VM isolation |
| privileged Docker by default | breakout and host compromise | isolate privileged jobs to dedicated ephemeral fleet |
| no metadata blocking | cloud credential theft | block IMDS / metadata access where unnecessary |
| static cloud keys on runner | secret theft | OIDC or short-lived federated credentials |
| broad internal network access | easy lateral movement | runner subnet isolation and egress allowlists |
| stale runner versions | feature and security drift | automatic image refresh or disciplined update process |
| no external logs | weak investigations | forward logs before destruction |
| fork PRs on self-hosted runners | trivial hostile code execution path | keep untrusted PRs on hosted or separate low-trust infrastructure |
| shared caches across trust zones | artifact poisoning and data leakage | separate cache scope and cleanup aggressively |
Review checklist
Governance
- who can attach repos to this runner fleet?
- who can edit workflow files that route jobs here?
- are protected refs and environments actually enforced?
Host security
- is the runner host hardened and patched?
- are admin tools, SSH keys, or cloud CLIs present when they are not needed?
- are OS logs and runner logs forwarded externally?
Executor
- shell, Docker, Kubernetes, or VM?
- is privileged mode enabled?
- are images pinned and pull policies sane?
Network
- which subnets, metadata endpoints, registries, clusters, and databases can jobs reach?
- can the fleet talk laterally?
Secrets
- how do jobs obtain cloud access?
- are secrets withheld from low-trust refs?
- is there evidence of secret rotation?