Self-Hosted CI Runners: Threat Model, Secrets, Trust & Build Access
Self-hosted CI runners give you full control over your build environment, but that control shifts the security burden onto you: the threat model changes from a managed sandbox to a shared, persistent machine that can access your code, secrets, and network. This article explains exactly what you are trusting when you run a self-hosted runner, how secrets and build access work, and how to design a setup that minimizes the risk of compromise.
What Changes with Self-Hosted Runners
A hosted runner (like GitHub Actions’ ubuntu-latest or GitLab’s shared runners) is a fresh virtual machine that is destroyed after each job. A self-hosted runner is a machine you provide—a VM, a container, or even a laptop—that the CI system installs agent software on. That agent waits for jobs, executes them, and reports results. The key difference is that the self-hosted runner is persistent: it has a filesystem, a network identity, and often access to other systems.
This persistence creates three distinct trust domains:
- Trust in the runner software – you must verify the agent itself is not malicious.
- Trust in the code you build – any repository that can trigger a job on your runner can execute arbitrary code on it.
- Trust in the environment – the runner’s OS, installed tools, and network position are all under your control, but also your responsibility.
The threat model is not “can an attacker break out of the sandbox?” but “what happens if the runner is compromised?” Because the runner is not ephemeral, a single malicious job can leave behind a backdoor that affects all future builds.
How Secrets Reach the Runner
CI systems inject secrets (API tokens, SSH keys, cloud credentials) into the job environment as environment variables or files. The runner process itself needs those secrets to pass them to the build script. This means the runner has access to every secret used by any job it executes.
On GitHub Actions, secrets are stored encrypted and are only decrypted when a job runs. But the decrypted value is available to the runner process, and any code running in the job can read it via environment variables or files. GitLab and Jenkins work similarly. The critical point: if your runner is compromised, all secrets used by jobs on that runner are exposed.
Common secret-handling mistakes include:
- Printing secrets to logs (even accidentally via debug output).
- Storing secrets in files that persist between jobs (e.g., writing to a shared temp directory).
- Using the same runner for multiple projects with different trust levels.
Build Access and the “Public Fork” Problem
The biggest risk with self-hosted runners is enabling them for public repositories or for pull requests from untrusted forks. If anyone can open a pull request, they can modify the build script to run arbitrary code on your runner. That code can:
- Read any file on the runner (including other projects’ source code if they share the same machine).
- Access the network and reach internal services (databases, staging servers, cloud metadata endpoints).
- Install persistent malware that survives after the job ends.
GitHub explicitly warns against using self-hosted runners with public repositories. GitLab has a similar warning for shared runners on public projects. The safe approach is to restrict self-hosted runners to private repositories where all contributors are trusted, or to use a separate, isolated runner for untrusted code.
Isolation Strategies
If you must run untrusted code, you need strong isolation. Options, from weakest to strongest:
- Container-based isolation – run each job in a fresh container (e.g., Docker). This is better than a bare VM but still shares the host kernel. A container escape could compromise the host.
- Virtual machine per job – use a hypervisor to spin up a fresh VM for each job. This is what hosted runners do. It is slower but much safer.
- Ephemeral runners – automatically create a new VM or container for each job and destroy it afterward. This prevents persistence of malware.
- Network isolation – put the runner on a separate network segment with no access to production systems. Even if compromised, the blast radius is limited.
For most teams, the practical minimum is: use ephemeral runners for anything that accepts code from people you don’t fully trust, and never give a self-hosted runner access to more than it needs.
Hardening the Runner Itself
Even for trusted private repositories, you should harden the runner:
- Use a dedicated user account with minimal privileges, not root or administrator.
- Disable password authentication and use SSH keys for any remote access.
- Keep the OS and all tools updated – an outdated runner is a target.
- Remove unnecessary software – the fewer packages, the smaller the attack surface.
- Set filesystem permissions so the runner user cannot write to system directories.
- Monitor runner activity – check logs for unexpected commands or network connections.
Secrets Management Best Practices
Do not rely solely on the CI system’s secret store. Use a dedicated secrets manager (like HashiCorp Vault, AWS Secrets Manager, or Azure Key Vault) and have the build script fetch secrets at runtime with short-lived credentials. This way, the runner does not hold long-lived secrets. For example:
- Store a token that can only be used to request a temporary credential from Vault.
- That temporary credential expires after the job.
- If the runner is compromised, the attacker gets a short-lived token, not a permanent key.
Also, never store secrets in your repository (even in encrypted form). Use the CI system’s secret feature, and rotate secrets regularly.
Choosing Between Hosted and Self-Hosted
Self-hosted runners are not inherently insecure, but they require more security expertise. If you need to run on specific hardware (e.g., for iOS builds, GPU testing, or legacy dependencies), self-hosted may be necessary. However, if you can use hosted runners, they are generally safer because the provider handles isolation and patching.
A hybrid approach is common: use hosted runners for public code and pull requests, and self-hosted runners only for trusted private builds. This gives you control where you need it and security where you don’t.
Frequently Asked Questions
Can a malicious pull request compromise my self-hosted runner?
Yes. If you enable self-hosted runners for pull requests from forks, the attacker can modify the build script to execute arbitrary commands. They can read files, access network resources, and install malware. This is the most common way self-hosted runners get compromised.
Is it safe to use Docker containers for isolation?
Containers provide some isolation but share the host kernel. A container escape vulnerability could allow an attacker to access the host. For untrusted code, a full VM per job is safer. For trusted code within your own team, containers are usually acceptable.
How do I keep secrets safe on a self-hosted runner?
Use the CI system’s secret store, but also rotate secrets regularly and consider a secrets manager that issues short-lived credentials. Never write secrets to disk, and ensure the runner user has minimal permissions.
What is an ephemeral runner?
An ephemeral runner is created fresh for each job and destroyed afterward. This prevents malware from persisting. Tools like GitHub’s auto-scaling runner controllers or GitLab’s runner autoscaling can implement this. It is the recommended way to run self-hosted runners for anything untrusted.
Can I use the same self-hosted runner for multiple projects?
Yes, but only if all projects have the same trust level. If one project is public or has less-trusted contributors, it can compromise the runner and then access the secrets and code of the other projects. Separate runners per trust level is safer.
What should I do if I suspect my runner is compromised?
Immediately remove the runner from the CI system, rotate all secrets that were used on it, and investigate the runner’s logs and network activity. Do not reuse the same machine until you have verified its integrity or reinstalled the OS.
Related
Method: the standard formula for this calculation, rounded as each result states. See how the calculators work or report an error.