You moved CI to self-hosted runners because the hosted ones were slow or expensive, or because builds needed access to something inside your network. Reasonable, and it changed the security model of your pipeline in a way that is easy to miss.
A hosted runner is a fresh machine that is destroyed afterwards. A self-hosted runner is a persistent machine, on your network, that executes code from your repository. If that repository accepts contributions, it executes code from strangers.
This post is what changes and how to run them safely. For whoever set them up to make builds faster.
Persistence is the first problem
The runner survives between jobs, so anything a job leaves behind is available to the next one:
- Files written outside the workspace.
- Credentials cached by a package manager, a cloud CLI, or a container tool.
- Processes still running after the job ended.
- A modified shell profile or an installed tool that later jobs invoke.
That means one compromised job can persist and wait for a job belonging to a more privileged repository. It also means an ordinary build can leak a credential to an unrelated one by accident.
Ephemeral runners are the answer, registered for a single job and destroyed afterwards. Most runner implementations support this and it removes the entire category. Reusing runners is the configuration that requires justification, not the other way round.
Fork contributions are the second
The dangerous combination is a public or contribution-accepting repository with a workflow that runs code from a pull request on a self-hosted runner.
Two distinctions matter and are easy to confuse:
A workflow triggered by an ordinary pull request runs in a restricted context: secrets are usually unavailable and the token is read-only. That is the safe default.
A workflow triggered in a way that grants the base repository's context to a fork's code is the dangerous one. The pattern exists because maintainers want to comment on pull requests or run tests needing secrets, and it has been a reliable source of compromise. If such a workflow checks out the fork's code and then runs it, an attacker submits a pull request and executes on your infrastructure with your secrets.
The rules that hold: do not run fork code on self-hosted runners at all, require approval for workflows from first-time contributors, and if you must use an elevated trigger, do not check out or execute the untrusted code in that job. Split it: one job handles untrusted code with no secrets, another consumes its output with secrets and no execution.
Injection through workflow inputs
Interpolating a value from a pull request into a shell command runs whatever is in it:
# unsafe: the title is attacker-controlled and becomes shell input
- run: echo "Building ${{ github.event.pull_request.title }}"
Branch names, titles, bodies, labels and author names are all attacker-controlled. Pass them as environment variables rather than interpolating into a command, so the shell receives them as data:
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "Building $TITLE"
Network position is the third problem
The reason many teams self-host is that builds need to reach something internal. That is exactly what makes the runner valuable to an attacker: it is a machine inside your network that executes code on demand.
Put runners in their own segment with egress rules, not in the network they are building for. Give them access to the specific artefact repository or service they need, rather than general reachability. A runner that can reach production because that was simpler is a build system with production access.
Credentials on the runner
Prefer short-lived credentials issued per job over long-lived secrets stored on the machine. Where your provider supports federated identity for CI, use it: the job gets a token scoped to what it needs, expiring in minutes, with nothing at rest to steal.
Where you must use static secrets, scope them per repository rather than per organisation, and keep them out of the runner's environment except for the step that needs them.
What to check
# Are runners ephemeral, or reused?
# On the runner, what is left over from previous jobs?
ls -la ~ /tmp 2>/dev/null | head -20
ls -la ~/.docker/config.json ~/.aws/credentials ~/.npmrc 2>/dev/null
# What can the runner reach?
curl -s -o /dev/null -w "%{http_code}\n" -m 5 http://169.254.169.254/
curl -s -o /dev/null -w "%{http_code}\n" -m 5 https://internal-service.example.com/
# Which workflows use an elevated trigger, and do they check out fork code?
grep -rn "pull_request_target\|workflow_run" .github/workflows/
grep -rn "runs-on:.*self-hosted" .github/workflows/
The first two commands, run on a live runner, are usually enough to settle the argument about whether ephemeral matters.
The concession
Ephemeral runners cost more: provisioning time per job, orchestration to manage them, and the loss of warm caches that made self-hosting attractive in the first place. For a private repository with no external contributors, where every job is written by someone you already trust with production, reused runners are a defensible choice.
The line is contributions. The moment code from outside your organisation can reach a runner, persistence and network position stop being convenience questions. And repositories change status: a private repository that goes public, or opens to contractors, changes the answer without anyone revisiting the runner configuration.
The implication
A self-hosted runner is a machine that executes arbitrary code on your network, by design, continuously.
If you cannot say whether it is ephemeral, what it can reach, and whether fork code ever runs on it, those are three short questions with answers you can get this afternoon.