Skip to content

Securing Self-Hosted GitHub Actions Runners

Lesson 8 of 10Advanced6 min readGitHub Actions & CI/CD · Actions SecurityVerified: GitHub Actions runner application, runner groups, August 2026

A GitHub-hosted runner is a fresh virtual machine that is destroyed after one job. Almost every security property people assume about CI comes from that sentence, and none of it is true by default on a machine you own.

PropertyHostedSelf-hosted by default
Fresh filesystem per jobYesNo
Destroyed after the jobYesNo
Isolated from your networkYesNo
Isolated from other repositoriesYesNo
Patched by someone elseYesNo

Each of these is recoverable with work. None of them is recovered by installing the runner and walking away.

Private repositories reduce but do not eliminate this. Anyone who can open a pull request in the repository can run code on the runner, so the trust boundary is your contributor list.

The default persistent runner keeps everything between jobs: files, environment changes, Docker images, credentials a workflow wrote to disk, and any dependency cache. Job A can read what job B left behind, and repository X can read what repository Y wrote.

Terminal window
./config.sh --url https://github.com/OWNER/REPO --token TOKEN --ephemeral

An ephemeral runner takes one job and deregisters. Combined with a fresh VM or container per registration, that restores the clean-slate property.

The second half of that sentence matters. --ephemeral on a long-lived machine that immediately re-registers still shares the filesystem. Ephemeral is only meaningful when the machine itself is recreated — which is what the Actions Runner Controller does with a pod per job.

The most common reason for self-hosting is access to something private, and the most common mistake is giving the runner access to everything private.

Put runners in their own network segment with explicit egress rules:

  • Allow outbound HTTPS to GitHub, and to whatever package registries the build needs.
  • Allow only the specific internal hosts a build legitimately requires — the artifact repository, the licence server.
  • Deny everything else, including lateral movement to other internal subnets, cloud metadata endpoints, and administrative interfaces.

Blocking the cloud instance metadata service deserves specific attention. A runner on a cloud VM inherits that instance’s IAM role, and any job can read credentials from the metadata endpoint — regardless of what the workflow was granted in GitHub. Block it at the network level, or run the instance with no role attached and use OIDC for cloud access instead.

Run the service as a dedicated unprivileged user. Not root, not a shared operations account, and not a user with sudo. Every job runs as this user, so its capabilities are the job’s capabilities.

Do not add the runner user to the docker group unless jobs genuinely need Docker. Membership of that group is effectively root: a container can mount the host filesystem. If jobs need containers, prefer a rootless runtime, and understand that “the runner user is unprivileged but is in the docker group” is not a meaningful restriction.

Give the machine no ambient cloud credentials. No instance profile, no ~/.aws/credentials, no gcloud application-default credentials. Workflows that need cloud access should obtain it per run through OIDC, so access is scoped to that workflow rather than to the machine.

Do not store secrets on the runner. They belong in GitHub secrets or environment secrets, injected per job.

Runner groups control which repositories may schedule onto which runners. Without them, at organisation scope, any repository in the organisation can run code on any runner — including one with production network access.

Organise groups by trust level and network zone, and restrict each to named repositories rather than to all. Availability of runner groups and the granularity of their access controls depend on the account’s plan and on whether runners are registered at organisation or enterprise level, so confirm what your organisation has rather than assuming the control exists.

Also restrict which workflows may use a group where that is offered, so a new workflow in an allowed repository cannot silently reach a sensitive runner.

Registration tokens are short-lived and single-use, and they permit adding a runner to your organisation. Treat them accordingly: generate them at registration time through the API rather than storing them in configuration management, and never commit one.

A leaked registration token lets an attacker attach their machine to your organisation, which then receives your jobs — including their secrets. Audit the runner list for machines you do not recognise.

Self-hosted runners need the monitoring any production host needs, plus a few specifics:

  • New runner registrations. An unexpected runner is a serious finding.
  • Runner user activity — process execution and outbound connections. A build that suddenly starts connecting to a new host is worth an alert.
  • Disk usage, which is the most common operational failure.
  • Runner version, since a runner too far behind stops being able to connect.
  • Job assignment, so a repository scheduling onto a group it should not reach is visible.

The runner writes diagnostic logs under _diag/. Ship them somewhere the runner user cannot modify — a job can otherwise edit the record of what it did.

  1. Private repositories only, with a known contributor list.

  2. Ephemeral runners, on machines recreated per job — typically pods managed by the Actions Runner Controller.

  3. A dedicated network segment, default-deny egress, metadata endpoint blocked.

  4. An unprivileged service user, no sudo, no docker group unless required.

  5. No ambient credentials; OIDC for anything cloud.

  6. Runner groups restricted to named repositories.

  7. Centralised logging off the host, and alerting on new registrations.

If that list looks like more work than the problem is worth, that is a genuine and common conclusion — and a reason to check whether caching or a larger hosted runner solves the original problem instead.

  1. On a disposable VM, register a persistent runner against a private test repository. Run a job that writes a file to the home directory, then a second, unrelated workflow that reads it. That is the state-leakage problem demonstrated.

  2. Re-register with --ephemeral and repeat. Confirm the file is gone.

  3. From a job, attempt to reach the cloud metadata endpoint. If it responds, you have found ambient credentials that every workflow can read.

  4. Check whether the runner user is in the docker group. If it is, confirm for yourself what a container can mount.

  5. Review your runner groups and list which repositories can reach each. Remove any access that is not deliberate.

  6. Set up an alert on new runner registrations in your organisation.

GitHub Actions Security ChecklistAudit your workflows against the failure modes that actually cause incidents. Free and complete.

Get the production security checklists and Actions hardening templates from the Professional Toolkit.