Install and secure a GitHub Actions self-hosted runner
Build a one-job Linux runner with restricted repository access, an unprivileged account, verified release, minimal network, external logs, immutable updates, fork isolation, and complete post-job destruction.
Provide custom trusted CI capacity without allowing fork or less-trusted workflow code to persist on a shared host, inherit secrets, reach broad internal networks, or silently outlive the runner update and teardown policy.
- Ubuntu Server 24.04 LTS
- GitHub Actions runner current supported release; maximum 30-day lag
- GitHub Actions GitHub.com
- Trust classification Approved private repositories, events, contributors, actions, reusable workflows, secrets, environments, networks, and separate untrusted pull-request execution.
- Disposable infrastructure Immutable Linux image, one-job VM and work disk, independent controller, hard TTL, capacity limits, and cryptographic image identity.
- GitHub governance Organization owner, restricted runner group, repository allowlist, protected branches/environments, audit access, and API identity for JIT orchestration.
- Least privilege Dedicated non-sudo user, no root-equivalent socket/groups, narrow outbound network, minimal GITHUB_TOKEN and federated identity policies.
- Operations and response External logs, runner/update/lifecycle metrics, kill switch, hosted fallback, credential inventory, and compromise runbook.
OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.
Full guide
What you will build
- A dedicated Linux self-hosted GitHub Actions runner image and one-job ephemeral execution path under a non-login, non-sudo runner account with no root-equivalent container socket.
- An organization runner group restricted to explicitly approved private repositories, precise labels, least-privilege workflow permissions, full-SHA action pins, and a separate untrusted pull-request path.
- A JIT/ephemeral lifecycle with external logs, update governance, negative security tests, monitored autoscaling, complete disk destruction, and a safe hosted-runner fallback.
- Trusted branch or approved deployment jobs reach only the intended group and one runner processes exactly one job before automatic deregistration and destruction.
- Fork and untrusted pull-request workflows cannot reach the runner, its internal network, caches, credentials, or persistent state.
- Operators can verify runner provenance, connectivity, update age, external logs, work-volume disposal, routing policy, and emergency disablement without exposing registration/JIT data.
Architecture
How the parts fit together
A factory creates a patched immutable Linux VM for each trusted job. The host runs only the GitHub runner under a dedicated user and can reach GitHub plus a narrow set of approved build services. An organization runner group restricts repository access. A controller requests one-time registration or JIT configuration, boots the runner, forwards _diag and lifecycle logs externally, and destroys the VM and disk after one job. Untrusted pull-request validation uses GitHub-hosted runners or a completely separate disposable low-trust pool.
- A trusted workflow event passes repository, branch/environment, permission, and action-pin policy and requests a restricted group/label.
- The controller creates a clean instance and registers it with one-time ephemeral or JIT configuration.
- The runner receives exactly one job, fetches only reviewed dependencies, and reaches only approved build destinations.
- Logs are acknowledged externally, GitHub deregisters the runner, and the controller destroys compute and disk before returning capacity.
- Untrusted pull requests remain on GitHub-hosted or a separate low-trust disposable pool and never share networks, secrets, caches, or identities.
Assumptions
- This guide targets an organization-owned private repository and a dedicated Linux runner image. Public repositories and fork-originated code do not run on this trusted pool.
- The operator uses the current supported GitHub Actions runner release and rebuilds immutable images for every release within GitHub's 30-day window, immediately for critical updates.
- The runner host is not a general server, deployment target, interactive workstation, or shared Docker host. It has console recovery and can be destroyed without preserving local state.
- Secrets come only from approved GitHub environments or short-lived identity federation after trust checks. Registration tokens and encoded JIT configuration are never stored in source, argv examples, logs, or machine images.
- The organization can forward diagnostics externally and has an approved GitHub-hosted or isolated low-trust fallback when self-hosted capacity is disabled.
Key concepts
- Self-hosted runner
- A machine operated by the organization that executes arbitrary workflow code with the permissions, network, files, and credentials exposed to its process.
- Ephemeral runner
- A runner registered for one job and automatically deregistered afterward. The infrastructure owner must still destroy its compute and storage.
- Just-in-time runner
- A single-job runner created from an encoded configuration returned by the GitHub REST API, suited to autoscaled immutable infrastructure.
- Runner group
- An organization or enterprise access boundary controlling which repositories may target a collection of runners.
- Label
- A routing attribute describing runner characteristics. Labels are not a security boundary without group/repository policy.
- Untrusted workflow
- Any job whose executable code, action, dependency, ref, or workflow definition can be influenced by a fork, external contributor, or less-trusted repository.
- Root-equivalent socket
- A service endpoint such as the Docker daemon socket that lets an unprivileged process control host-level workloads and normally obtain root-equivalent access.
- Immutable runner image
- A versioned read-only build artifact recreated through a reviewed pipeline rather than patched interactively or reused after jobs.
Before you copy
Values used in this guide
{{githubOrg}}GitHub organization that owns the runner group.
Example: acme-platform{{githubRepo}}Approved private repository allowed to use the group.
Example: payments-api{{runnerGroup}}Restricted organization runner group name.
Example: trusted-linux-deploy{{runnerLabel}}Capability/trust label used with the runner group.
Example: linux-x64-ephemeral{{runnerUser}}Dedicated unprivileged local service account.
Example: actions-runner{{runnerHome}}Dedicated runner installation/home directory.
Example: /opt/actions-runner{{runnerArchiveUrl}}Official GitHub release archive URL copied from the current runner setup instructions.
Example: https://github.com/actions/runner/releases/download/vX.Y.Z/actions-runner-linux-x64-X.Y.Z.tar.gz{{runnerArchive}}Local filename for the verified runner release archive.
Example: actions-runner-linux-x64.tar.gz{{runnerSha256}}Official SHA-256 value published for the selected runner archive.
Example: 64-hex-character-official-checksum{{checkoutCommit}}Reviewed full 40-character commit SHA for actions/checkout.
Example: reviewed-full-commit-shaSecurity and production boundaries
- GitHub warns that self-hosted runners can be persistently compromised by workflow code and should almost never serve public repositories. A private fork can still be hostile.
- Never place the runner user in sudo, docker, lxd, libvirt, disk, or similar root-equivalent groups. Use a separately isolated builder pool when a job needs privileged capability.
- Runner-group access is the primary repository boundary; labels refine routing but can be copied and are not proof of host capability.
- Registration tokens and JIT configuration are short-lived credentials. Keep them only in controller memory or a protected prompt and redact all provisioning/debug output.
- Pin third-party actions to reviewed full commit SHAs, minimize GITHUB_TOKEN permissions, gate environments, and prohibit pull_request_target from executing fork-controlled content.
Stop before continuing if
- Stop if a public repository, fork, pull-request-controlled workflow/action, or unreviewed repository can target the trusted group.
- Stop if the runner account has sudo, a host management socket, writable service/image files, shared work storage, or broad production network reachability.
- Stop if registration/JIT data, environment secrets, or OIDC tokens appear in command text, process listings captured by other tenants, images, logs, or outputs.
- Stop production autoscaling if external _diag/lifecycle logs are not acknowledged before teardown or the instance can receive a second job.
- Stop scheduling when the runner image exceeds the update policy, GitHub requires a critical update, or negative routing/destruction tests fail.
decision
Classify workflows and create a restricted runner group
Inventory every event, repository, action, secret, environment, artifact, network target, and actor that could influence a job. Create an organization group that grants access only to explicitly approved private repositories. Route public and fork-originated pull requests to GitHub-hosted or a separate low-trust pool.
Why this step matters
A runner executes repository-controlled code inside the organization's network. Trust classification and group access therefore matter more than the operating-system installation.
What to understand
Review workflow definitions and transitive actions as executable dependencies, including reusable workflows and composite actions.
Separate build/test, deployment, signing, and privileged network access into different runner groups and environments.
GitHub explicitly warns that forked public and private repository code can compromise self-hosted machines.
System changes
- The command prints reviewed nonsecret routing values. Group/repository access is configured in GitHub organization settings or its authenticated API by an owner.
Syntax explained
runnerGroup- Repository access boundary for the trusted infrastructure.
runnerLabel- Capability selector combined with the group in workflow runs-on.
fork-policy- Explicitly prevents untrusted pull-request code from reaching the trusted pool.
Values stay on this page and are never sent or saved.
printf '%s\n' 'organization={{githubOrg}}' 'repository={{githubRepo}}' 'group={{runnerGroup}}' 'label={{runnerLabel}}' 'fork-policy=hosted-or-isolated-only'organization=acme-platform repository=payments-api group=trusted-linux-deploy label=linux-x64-ephemeral approved repositories: 1 public repositories: 0 fork pull requests on trusted group: denied group default access: restricted
Checkpoint: Verify this layer before continuing
Continue whenThe group defaults to restricted access, contains only approved repositories, and every event class has a documented trusted or untrusted execution path.
Stop whenStop if any public/fork workflow can target the group, repository ownership is unclear, or a universal group spans incompatible secrets/networks.
If this step fails
A repository owner requests broad default-group access for convenience.
Likely causeWorkflow routing was designed around availability rather than trust boundaries.
List the repository's workflows, triggers, secrets, environments, and internal destinations.Identify a hosted or lower-trust execution path for nonprivileged tests.
ResolutionCreate a narrow repository/group policy and separate privileged jobs; do not expand the trusted default group.
Security notes
- Treat runner-group administration as privileged infrastructure access and audit every membership change.
Alternatives
- Use only GitHub-hosted runners when no job requires private network or custom hardware.
Stop conditions
- Stop if any public/fork workflow can target the group, repository ownership is unclear, or a universal group spans incompatible secrets/networks.
verification
Approve a one-job lifecycle and immutable image bill of materials
Define the current supported runner release, Ubuntu 24.04 LTS base image digest, package inventory, update SLA, external log destination, work-volume lifecycle, maximum job duration, network policy, capacity limits, and exact teardown acknowledgment. Prefer JIT for autoscaling and ephemeral registration for a manual pilot.
Why this step matters
One-job semantics require more than the GitHub flag: the machine image, storage, controller, logging, timeout, and deletion evidence must all agree that no second job can inherit state.
What to understand
Version the entire image, not only actions/runner. OS packages, CA trust, language tools, and monitoring agents are part of the attack surface.
Use external logs because ephemeral runner files disappear; GitHub recommends forwarding them before production autoscaling.
Define a hard job/runtime TTL so a compromised or hung process cannot hold an internal foothold indefinitely.
System changes
- The commands inspect the pilot OS, virtualization, time, and mounts. The associated architecture record freezes an image bill of materials and lifecycle policy.
Syntax explained
systemd-detect-virt- Confirms the expected disposable virtualization boundary.
timedatectl show- Checks synchronized UTC time needed for TLS, logs, and short-lived credentials.
findmnt- Shows filesystem sources/options and proves work storage is a distinct disposable resource where designed.
uname -a; cat /etc/os-release; systemd-detect-virt; timedatectl show --property=NTPSynchronized --property=Timezone; findmnt --output TARGET,SOURCE,FSTYPE,OPTIONS / /optLinux gha-pilot 6.8.0-64-generic x86_64 PRETTY_NAME="Ubuntu 24.04.2 LTS" virtualization: kvm NTPSynchronized=yes Timezone=Etc/UTC / /dev/mapper/os-root ext4 rw,nodev /opt /dev/mapper/runner-work ext4 rw,nodev,nosuid runner release policy: current, <=30 days from GitHub release maximum jobs per instance: 1 work volume destruction: required
Checkpoint: Verify this layer before continuing
Continue whenImage inputs/digests, supported runner release, external logs, one-job limit, TTL, teardown, network, and update owners are approved and reproducible.
Stop whenStop if the host is shared, stateful, manually patched, cannot be destroyed, lacks external logs, or has no update owner.
If this step fails
The image factory uses mutable latest references.
Likely causeThe build cannot prove which OS/tool artifact was used for the runner image.
Inspect image-builder source, lock files, registry/cloud image IDs, and signed release metadata.Rebuild twice from the same locked inputs and compare the resulting inventory.
ResolutionPin immutable image/package inputs or record repository snapshots, then regenerate and sign the runner image before use.
Security notes
- Image builders are supply-chain systems and must be isolated from the workflows their images later execute.
Alternatives
- Use a managed ephemeral VM image pipeline if the organization cannot operate a secure custom factory.
Stop conditions
- Stop if the host is shared, stateful, manually patched, cannot be destroyed, lacks external logs, or has no update owner.
command
Create a dedicated nonprivileged runner account and filesystem
On the clean image, create a dedicated local account and installation path owned only by that account. Do not grant sudo, password login, docker/libvirt/lxd groups, host sockets, or write access to system services and image configuration.
Why this step matters
Workflow commands inherit the runner account's authority. A dedicated account and narrow filesystem constrain accidental damage and remove obvious root-equivalent escalation paths.
What to understand
Membership in docker, lxd, libvirt, disk, or similar groups can provide host control even when uid is not zero.
The runner must not own its systemd unit, image bootstrap, telemetry configuration, or network policy.
The work directory is disposable and must not be mounted from a shared persistent volume or previous runner.
System changes
- Creates one local account plus runner/work directories and ownership. It changes the immutable image recipe and should not be performed ad hoc on reused hosts.
Syntax explained
useradd --create-home- Creates a separate local identity and controlled home path.
install --directory --mode 0750- Creates private directories readable/writable only by owner and root.
sudo -l -U- Verifies no sudo commands are delegated to the runner user.
Values stay on this page and are never sent or saved.
sudo useradd --create-home --home-dir '{{runnerHome}}' --shell /bin/bash '{{runnerUser}}'; sudo install --directory --owner '{{runnerUser}}' --group '{{runnerUser}}' --mode 0750 '{{runnerHome}}' '{{runnerHome}}/_work'; id '{{runnerUser}}'; sudo -l -U '{{runnerUser}}' || true; getent group docker lxd libvirtuid=991(actions-runner) gid=991(actions-runner) groups=991(actions-runner) User actions-runner is not allowed to run sudo on gha-pilot. runner home: /opt/actions-runner owner=actions-runner mode=0750 work directory: /opt/actions-runner/_work owner=actions-runner mode=0750 docker/lxd/libvirt membership for actions-runner: none password authentication: locked by image policy
Checkpoint: Verify this layer before continuing
Continue whenThe account has only its primary group, no sudo/root-equivalent groups, owns only runner/work paths, and cannot alter service/image policy.
Stop whenStop if any privileged group, sudo rule, management socket, shared writable path, or interactive remote login exists.
If this step fails
A build requirement claims it needs the Docker socket.
Likely causeThe job combines untrusted build instructions with a root-equivalent host daemon.
Identify the exact build operation and trust level.Evaluate rootless BuildKit or a separate disposable builder service with no production network.
ResolutionMove container building to a dedicated isolated supply-chain runner/builder; do not add the general runner user to docker.
Security notes
- File ownership is not isolation against kernel vulnerabilities, so retain one-job VM destruction and patching.
Alternatives
- Use an ephemeral container only when its host and runtime provide a real per-job boundary and no shared daemon socket.
Stop conditions
- Stop if any privileged group, sudo rule, management socket, shared writable path, or interactive remote login exists.
command
Download and verify the official runner release
Copy the current Linux x64 archive URL and SHA-256 value from GitHub's runner setup instructions, download over verified HTTPS, validate the checksum before extraction, inventory files, and make the installation immutable in the image. Never curl a release script directly into a shell.
Why this step matters
A runner is a remote code-execution agent. Verifying the official release archive before adding it to a trusted image reduces substitution and download corruption risk.
What to understand
Obtain URL/checksum through the authenticated organization setup UI or official actions/runner release and peer-review them in the image change.
Download and extract as the dedicated runner user so config/runtime files are not accidentally root-owned. Run only the verified host dependency installer with sudo, then reassert runner ownership.
If automatic updates are disabled for immutable images, rebuild within 30 days of every runner release and immediately for critical security releases.
System changes
- Downloads and extracts the runner software, installs documented host dependencies, and records the version in the image. It does not register the runner.
Syntax explained
curl --fail --location- Fails on HTTP errors and follows the official release redirect.
--proto '=https' --tlsv1.2- Restricts the transfer to HTTPS with at least TLS 1.2.
sha256sum --check --strict- Stops extraction unless the archive exactly matches the official digest.
installdependencies.sh- Runs the verified host dependency installer with root authority after release verification; the runner binaries themselves remain owned by the runner account.
Values stay on this page and are never sent or saved.
sudo -H -u '{{runnerUser}}' -- bash -c "cd '{{runnerHome}}' && curl --fail --location --proto '=https' --tlsv1.2 --output '{{runnerArchive}}' '{{runnerArchiveUrl}}' && printf '%s %s\n' '{{runnerSha256}}' '{{runnerArchive}}' | sha256sum --check --strict && tar --extract --gzip --file '{{runnerArchive}}'"; cd '{{runnerHome}}' && sudo ./bin/installdependencies.sh; sudo chown --recursive '{{runnerUser}}:{{runnerUser}}' '{{runnerHome}}'; sudo -H -u '{{runnerUser}}' -- bash -c "cd '{{runnerHome}}' && ./bin/Runner.Listener --version"actions-runner-linux-x64.tar.gz: OK Runner.Listener Runner.Worker Runner.PluginHost Dependencies: satisfied Runner version: current supported X.Y.Z Archive source: official actions/runner release Checksum source: GitHub runner setup instructions Image manifest updated: runner-image-2026-07-28.1
Checkpoint: Verify this layer before continuing
Continue whenChecksum passes, expected binaries exist, dependency setup succeeds, version matches the reviewed release, and the final image manifest/digest is recorded.
Stop whenStop on checksum mismatch, redirected/unofficial host, unsigned image change, dependency from an unapproved repository, or an already-outdated runner release.
If this step fails
The checksum fails.
Likely causeThe archive/download is corrupt, wrong architecture/version was selected, or the source was substituted.
Delete the failed archive without extracting it.Reconfirm URL and digest independently in GitHub's official setup/release page and inspect proxy integrity.
ResolutionDownload a fresh archive from the official source and proceed only after exact verification; escalate any persistent mismatch as a supply-chain incident.
Security notes
- Do not make runner binaries writable by workflow jobs after the image is published.
Alternatives
- Use a package/image source mirrored internally only if it preserves official digest verification and provenance.
Stop conditions
- Stop on checksum mismatch, redirected/unofficial host, unsigned image change, dependency from an unapproved repository, or an already-outdated runner release.
config
Apply network, process, filesystem, and telemetry boundaries
Allow outbound traffic only to GitHub endpoints and named build dependencies required by the approved workflows; deny inbound service access. Forward systemd, image identity, runner _diag, audit, DNS/proxy, and teardown events externally. Set resource and job TTL limits, read-only image paths, disposable work storage, and no cross-runner cache.
Why this step matters
Self-hosting is often chosen for internal access, but broad internal reach turns one workflow compromise into lateral movement. Network and telemetry controls must be part of the runner product.
What to understand
GitHub endpoint requirements can change; maintain the allowlist from official communication requirements and monitor failures rather than opening unrestricted egress.
Logs must leave before destruction and must not contain registration/JIT configuration, tokens, secrets, or full environment dumps.
Resource and TTL limits contain denial-of-service and persistent reverse-shell behavior; an expired runner is destroyed, not extended indefinitely.
System changes
- Applies network ACL/proxy, filesystem mount, process/service, resource, TTL, and external log collection policy in the immutable image/orchestrator.
Syntax explained
test -w/test ! -w- Proves the runner can write only its disposable work path, not system service definitions.
curl --head- Checks TLS/HTTP reachability without sending credentials or downloading content.
findmnt- Confirms the work directory is backed by the intended disposable mount.
Values stay on this page and are never sent or saved.
sudo -u '{{runnerUser}}' test -w '{{runnerHome}}/_work' && sudo -u '{{runnerUser}}' test ! -w /etc/systemd/system; curl --fail --silent --show-error --head https://github.com; curl --fail --silent --show-error --head https://api.github.com; systemctl is-active systemd-journald; findmnt '{{runnerHome}}/_work'runner work directory writable: yes /etc/systemd/system writable by runner: no github.com HTTPS: 200 api.github.com HTTPS: 200 inbound listener policy: denied internal destinations allowed: registry-ci.corp.example:443 only system journal: active external log canary: acknowledged event gha-runner-canary-071 job TTL: 45 minutes instance TTL: 60 minutes
Checkpoint: Verify this layer before continuing
Continue whenGitHub and approved build endpoints work, unapproved/inbound paths fail, logs are externally acknowledged, TTL/resources are enforced, and runner jobs cannot alter controls.
Stop whenStop if the runner needs broad production network, external logs fail, controls are job-writable, or a required endpoint cannot be defined.
If this step fails
GitHub connectivity works in the shell but the runner cannot receive jobs.
Likely causeAdditional documented GitHub endpoints, proxy WebSocket/long-poll behavior, certificate trust, or DNS are missing.
Inspect runner _diag network errors and proxy logs.Compare the official communication requirements with the allowlist and TLS interception policy.
ResolutionAdd only the missing documented endpoint/protocol, test with a canary, and retain default-deny boundaries.
Security notes
- Do not log request authorization headers, OIDC request variables, or full process environments.
Alternatives
- Use a controlled egress proxy that authenticates the runner identity and records destinations without retaining secrets.
Stop conditions
- Stop if the runner needs broad production network, external logs fail, controls are job-writable, or a required endpoint cannot be defined.
command
Register one manual pilot as ephemeral without a token in argv
Create a short-lived organization registration token in GitHub settings, open a trusted console as the runner user, and run config.sh without --token so it prompts interactively. Select the restricted organization URL, one-job ephemeral mode, exact labels, name, and work directory; do not capture the prompt or copy the token into shell history.
Why this step matters
A manual ephemeral pilot validates group, labels, network, and one-job behavior without embedding a token in a command line. Production autoscaling should replace this prompt with protected JIT orchestration.
What to understand
Create the token immediately before registration and use it only once within its short lifetime.
--disableupdate is appropriate only because the image factory has a monitored <=30-day update SLA and critical-release emergency path.
Confirm the runner lands in the restricted group; labels alone do not grant the intended repository boundary.
System changes
- Writes local runner registration metadata containing credentials and creates an organization runner entry eligible for exactly one job.
Syntax explained
--url- Registers at organization scope so group access policy can be enforced.
--ephemeral- Requests automatic deregistration after the first completed job.
--labels- Adds the reviewed capability selector used with runner-group routing.
--disableupdate- Prevents runtime mutation; the image factory must replace the runner release within GitHub's deadline.
Values stay on this page and are never sent or saved.
sudo -H -u '{{runnerUser}}' -- bash -c "cd '{{runnerHome}}' && ./config.sh --url 'https://github.com/{{githubOrg}}' --ephemeral --labels '{{runnerLabel}}' --name 'gha-pilot-071' --work '_work' --disableupdate"Enter the registration token: [input not echoed or recorded] Authentication: succeeded Runner group selected: trusted-linux-deploy Runner name: gha-pilot-071 Labels: self-hosted, Linux, X64, linux-x64-ephemeral Ephemeral: true Work folder: _work Automatic runner update: disabled by immutable-image policy Secret/token output: none
Checkpoint: Verify this layer before continuing
Continue whenThe runner is ephemeral, in the restricted group, exposes only intended labels, stores no token in history/output/image, and remains offline until the pilot service starts.
Stop whenStop if the prompt is captured, group defaults are broad, registration becomes persistent, token appears in argv/logs, or image update governance is not active.
If this step fails
The runner appears in the default group.
Likely causeOrganization setup did not select the intended group or the registration flow lacks the required policy.
Keep the runner offline and inspect organization runner-group membership/access.Confirm the group ID/policy used by the future JIT API.
ResolutionMove or re-register the runner in the restricted group before any job; never run a trusted job in the broad default group.
Security notes
- The local .runner/.credentials material is sensitive until teardown; restrict it to the runner user and destroy it with the instance.
Alternatives
- Use the organization generate-jitconfig API from the autoscaler for production one-job registration, treating encoded_jit_config as a secret.
Stop conditions
- Stop if the prompt is captured, group defaults are broad, registration becomes persistent, token appears in argv/logs, or image update governance is not active.
config
Route only trusted jobs and pin workflow dependencies
Use group plus label for the trusted job, default workflow permissions to read, grant only job-specific permissions, pin every third-party action to a reviewed full commit SHA, and gate sensitive environments. Fork pull-request tests use GitHub-hosted runners and never pass artifacts or caches directly into the trusted job without validation.
Why this step matters
Runner isolation is defeated if broad workflow triggers, mutable actions, excessive tokens, or fork-controlled artifacts reach it. The workflow must express the same trust boundary as the infrastructure.
What to understand
Pin action code by full commit SHA and review updates; a release tag is still mutable.
Set permissions explicitly because the default can vary by repository/organization policy and jobs should not inherit unused write access.
Do not use pull_request_target to check out and execute fork code in the privileged context. Validate artifacts before crossing trust zones.
System changes
- Changes the workflow routing, GitHub token permissions, environment gate, action dependency pins, and executable deploy path.
Syntax explained
runs-on.group- Requires the job to use the restricted runner-group boundary.
runs-on.labels- Requires the matching ephemeral Linux capability within that group.
permissions: contents: read- Starts from least-privilege repository access.
persist-credentials: false- Prevents checkout from retaining the GitHub token in Git configuration for later steps.
.github/workflows/trusted-deploy.ymlValues stay on this page and are never sent or saved.
name: trusted-deploy
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
untrusted-pr-test:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@{{checkoutCommit}}
trusted-deploy:
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
runs-on:
group: {{runnerGroup}}
labels: {{runnerLabel}}
environment: production
permissions:
contents: read
steps:
- uses: actions/checkout@{{checkoutCommit}}
with:
persist-credentials: false
- run: ./ci/verified-deploy.sh
workflow syntax: valid trusted job route: group=trusted-linux-deploy label=linux-x64-ephemeral trusted trigger: push to protected main or approved workflow_dispatch fork pull_request route: ubuntu-latest default GITHUB_TOKEN permission: contents:read third-party action references pinned to full commit SHA: 100% pull_request_target executes fork-controlled code: false
Checkpoint: Verify this layer before continuing
Continue whenTrusted events reach group+label with environment approval, fork PRs use hosted isolation, permissions are minimal, all actions use full SHAs, and no untrusted artifact crosses unverified.
Stop whenStop if pull_request/fork code can reach the group, pull_request_target executes it, an action uses a tag, token writes are unnecessary, or environment gates are absent.
If this step fails
The trusted job is skipped on an approved protected push.
Likely causeThe event condition, branch protection, environment, group access, or labels do not match.
Inspect the workflow event/ref and job condition evaluation.Compare group repository access, environment deployment rules, and runner labels.
ResolutionCorrect the narrow condition or policy; do not add pull_request or broaden group access as a scheduling workaround.
Security notes
- Shell scripts in the repository are executable dependencies and require protected-branch review just like actions.
Alternatives
- Use a reusable workflow owned in a tightly controlled repository, still pinned and permission-scoped by callers.
Stop conditions
- Stop if pull_request/fork code can reach the group, pull_request_target executes it, an action uses a tag, token writes are unnecessary, or environment gates are absent.
verification
Run positive and negative routing tests before secrets exist
With no production secrets or internal write access, test a protected-branch canary, an untrusted fork PR, a mismatched label, an unauthorized repository, and a mutable-action policy violation. Confirm only the trusted canary reaches the self-hosted group and every denied case is observable.
Why this step matters
Negative tests prove that policy fails closed. Testing before secrets and production network access limits the cost of discovering a routing mistake.
What to understand
Use a benign canary repository/workflow and synthetic values. Never ask an external contributor to attempt compromise.
A queued wrong-label job is expected; cancel it after proving no matching runner appears.
Record GitHub audit/workflow evidence and runner lifecycle correlation for every denial.
System changes
- The command documents test cases. The tests create synthetic workflow runs and one ephemeral runner job but no production deployment or credentials.
Syntax explained
trusted-push- Positive control proving intended routing and execution.
fork-pr- Negative trust test proving fork-originated code stays off the trusted infrastructure.
unauthorized-repo- Tests runner-group repository access independently of labels.
mutable-action- Tests dependency policy rejection before action execution.
printf '%s\n' 'trusted-push=expected:self-hosted-one-job' 'fork-pr=expected:github-hosted' 'wrong-label=expected:queued-no-match' 'unauthorized-repo=expected:group-denied' 'mutable-action=expected:policy-fail'trusted-push -> gha-pilot-071: passed fork-pr -> GitHub-hosted runner: passed fork-pr access to trusted group: denied wrong-label -> no trusted match: passed unauthorized repository -> group unavailable: passed mutable action reference -> policy check failed as expected production secrets present during tests: 0 unexpected internal destinations reached: 0
Checkpoint: Verify this layer before continuing
Continue whenOnly the protected trusted canary runs self-hosted; every untrusted/mismatched case fails closed with audit evidence and zero secrets/internal writes.
Stop whenStop and disable the group immediately if any unauthorized event/repository/action reaches the runner or denial lacks evidence.
If this step fails
The fork PR can see the trusted group but no runner is online.
Likely causeGroup repository access is broad even though current capacity masks the exposure.
Inspect organization group access and repository runner settings.Review audit logs for prior routing attempts.
ResolutionRemove the repository/default access, invalidate runner registrations, and repeat the test with an online secret-free canary before production.
Security notes
- Negative tests must not include real secrets because a routing bug is precisely what they are intended to find.
Alternatives
- Enforce the same scenarios continuously in policy-as-code and a scheduled canary.
Stop conditions
- Stop and disable the group immediately if any unauthorized event/repository/action reaches the runner or denial lacks evidence.
command
Run one job, forward diagnostics, and prove destruction
Start the pilot runner under the dedicated account or a root-owned systemd unit configured for that user, run one benign trusted canary, wait for external log acknowledgment, and destroy the VM and work volume. Confirm GitHub deregistration and that a second matching job cannot land on the old identity.
Why this step matters
The real security boundary is the complete one-job lifecycle: registration, execution, external evidence, automatic deregistration, and irreversible destruction of every writable layer.
What to understand
Use timeout plus orchestrator TTL so a hostile process cannot keep the instance indefinitely.
The runner's exit does not erase data. The controller must delete the VM, local work disk, swap, snapshots, and any job-specific cache.
External log acknowledgment precedes destruction, but log loss must never cause indefinite reuse of a potentially compromised host.
System changes
- Starts the runner and executes one workflow. The orchestration then deletes the instance, disk, registration, network identity, and ephemeral credentials.
Syntax explained
timeout --signal=TERM 45m- Bounds runner execution and requests graceful termination at the approved job TTL.
./run.sh- Starts the already registered runner as the unprivileged user.
Values stay on this page and are never sent or saved.
sudo -H -u '{{runnerUser}}' -- bash -c "cd '{{runnerHome}}' && exec timeout --signal=TERM 45m ./run.sh"Connected to GitHub Listening for Jobs Running job: runner-one-job-canary Job completed: Success Ephemeral runner removed from GitHub External _diag/lifecycle log acknowledgment: received Instance shutdown requested: true Work volume deletion: confirmed Runner record after teardown: absent Second matching job on old runner ID: impossible
Checkpoint: Verify this layer before continuing
Continue whenOne job succeeds, external logs are acknowledged, GitHub removes the runner, all writable resources are destroyed, and a second job requires a new identity/instance.
Stop whenStop production if job count exceeds one, teardown/log acknowledgment fails, the disk persists, the runner record remains online, or old identity can reconnect.
If this step fails
The job completes but the service restarts and reconnects.
Likely causeA persistent systemd restart policy or autoscaler treats normal ephemeral exit as failure.
Disable the runner/group and inspect unit Restart policy plus controller lifecycle state.Verify the runner record's ephemeral flag and instance job counter.
ResolutionDestroy the instance, correct the unit/controller to treat one-job exit as success, and repeat the negative second-job test.
Security notes
- Assume job code can read everything reachable by the runner user, including diagnostics and other processes in the same host.
Alternatives
- For production, use JIT configuration so the controller receives a one-job registration designed for autoscaling.
Stop conditions
- Stop production if job count exceeds one, teardown/log acknowledgment fails, the disk persists, the runner record remains online, or old identity can reconnect.
verification
Validate update governance, observability, and emergency disablement
Monitor the current runner release, immutable image age/digest, online/busy/ephemeral inventory, queue time, provisioning failures, job/instance TTL, external log gaps, network denials, and teardown failures. Exercise disabling the runner group and routing an eligible nonprivileged job to the hosted fallback.
Why this step matters
A secure runner degrades quickly when releases, provisioning, logging, routing, or teardown drift. Operational metrics and an exercised kill switch keep the control effective after launch.
What to understand
GitHub can stop assigning jobs after 30 days without an update or immediately for a critical update.
Alert on any non-ephemeral runner, more than one job per instance, stale image, missing logs, unexpected repository, broad egress, or undeleted disk.
The kill switch should disable group access/capacity without deleting evidence and should not require access to the potentially compromised runner.
System changes
- The shown output is monitoring evidence. The drill temporarily disables runner-group capacity and exercises a hosted fallback for an eligible job.
Syntax explained
runner-release-lag-days- Measures immutable image freshness against GitHub runner releases.
instances with jobs >1- Detects violation of the one-job isolation contract.
teardown/log gaps- Detects residual compromised resources or lost forensic evidence.
group-disable-test- Proves owners can stop trusted scheduling independently of runner hosts.
printf '%s\n' 'runner-image-age-days=4' 'runner-release-lag-days=0' 'ephemeral-inventory=all' 'teardown-failures=0' 'external-log-gaps=0' 'group-disable-test=passed' 'hosted-fallback=passed'runner image revision: runner-image-2026-07-28.1 runner release lag: 0 days instances with jobs >1: 0 provision p95: 42s queue p95: 55s TTL violations: 0 external log gaps: 0 teardown failures: 0 organization group disabled in drill: 31s eligible hosted fallback canary: passed
Checkpoint: Verify this layer before continuing
Continue whenAll metrics meet budget, critical alerts deliver, stale/unsafe runners are refused, the group disables promptly, and hosted fallback is understood.
Stop whenStop scheduling on stale release, lifecycle/log anomaly, unexpected repository, destructive capacity behavior, or an untested kill switch.
If this step fails
A critical runner release appears while jobs are queued.
Likely causeThe immutable image pipeline cannot replace capacity quickly enough.
Disable affected runner capacity and identify which jobs can safely use hosted fallback.Build and verify a canary image from the official release/digest.
ResolutionKeep old runners disabled, deploy the verified image, run routing/lifecycle negatives, then reopen capacity gradually.
Security notes
- Monitoring must not ingest workflow secrets, registration/JIT configuration, or complete environment dumps.
Alternatives
- Use GitHub-hosted runners as the fail-safe for jobs that do not need private capabilities.
Stop conditions
- Stop scheduling on stale release, lifecycle/log anomaly, unexpected repository, destructive capacity behavior, or an untested kill switch.
warning
Document compromise recovery and remove every persistent credential
Write the response sequence for a malicious workflow, unexpected repository, exposed secret, stale runner, or failed destruction. Disable group access, stop new capacity, isolate affected instances, preserve external evidence, rotate every reachable credential, review repository/action changes, rebuild images, and reopen only after negative tests pass.
Why this step matters
Workflow code has the same opportunity as an attacker once it reaches the runner. Recovery must assume all accessible files, tokens, services, caches, networks, and image-management paths were examined.
What to understand
Preserve GitHub audit/workflow metadata and external runner/network logs; do not depend on the compromised disk as the only evidence.
Rotate environment, repository, cloud/OIDC trust, registry, package, deploy, and internal service credentials according to actual reachability.
A clean VM is insufficient if the malicious workflow or mutable action remains on a protected branch.
System changes
- The documented incident actions disable scheduling, isolate/destroy capacity, rotate trust, rebuild images, and may interrupt deployments.
Syntax explained
disable-runner-group- Stops new trusted jobs without requiring host cooperation.
preserve-external-evidence- Retains logs and audit data outside the compromised machine.
rotate-reachable-credentials- Invalidates every secret/identity the runner could obtain, not only the registration token.
repeat-negative-tests- Requires proof that routing and one-job destruction fail closed before reopening.
printf '%s\n' 'disable-runner-group' 'stop-autoscaler' 'isolate-instance' 'preserve-external-evidence' 'rotate-reachable-credentials' 'rebuild-image' 'repeat-negative-tests'incident runbook: IR-CI-071 approved runner-group kill switch owner: Platform Security autoscaler stop owner: CI Operations evidence source independent of runner: yes credential inventory by runner class: complete known-good image digest: recorded reopen gate: security review plus all negative tests
Checkpoint: Verify this layer before continuing
Continue whenOwners, kill switches, credential inventory, independent evidence, known-good image, and reopen criteria are tested in a tabletop and canary drill.
Stop whenDo not reopen because the instance was deleted; stop until workflow/action provenance, credentials, image factory, routing, and negatives are trusted again.
If this step fails
The team cannot enumerate credentials reachable by the runner.
Likely causeSecrets and network identities were added organically without a per-runner-class dataflow inventory.
Review workflow permissions, environments, OIDC trust conditions, registry/package access, network policy, and service logs.Assume exposure for ambiguous paths and prioritize containment/rotation.
ResolutionComplete the credential/dataflow inventory, rotate uncertain credentials, narrow future reachability, and validate with synthetic jobs.
Security notes
- Do not collect or copy secret values while building the inventory; record identity, scope, owner, expiry, and rotation action.
Alternatives
- Keep the trusted pool disabled and use hosted runners for safe nonprivileged work while investigation continues.
Stop conditions
- Do not reopen because the instance was deleted; stop until workflow/action provenance, credentials, image factory, routing, and negatives are trusted again.
Finish line
Verification checklist
id '{{runnerUser}}'; sudo -l -U '{{runnerUser}}' || trueThe dedicated account has no sudo or root-equivalent group/socket and owns only disposable runner paths.printf '%s %s\n' '{{runnerSha256}}' '{{runnerArchive}}' | sha256sum --check --strictThe selected current official runner archive matches GitHub's published SHA-256 and the immutable image records its version/digest.printf '%s\n' '{{runnerGroup}}' '{{runnerLabel}}' '{{githubOrg}}/{{githubRepo}}'Only the approved private repository and trusted events can target group plus label; actions are full-SHA pinned with least permissions.printf '%s\n' 'fork PR denied' 'unauthorized repo denied' 'wrong label no match'Fork, unauthorized repository, and mismatched-label tests never reach trusted infrastructure and produce audit evidence.printf '%s\n' 'ephemeral deregistered' 'external logs acknowledged' 'VM and disk deleted' 'second job requires new runner'The runner executes exactly one canary, disappears from GitHub, externalizes logs, and leaves no reusable compute/storage/identity.Recovery guidance
Common problems and safe checks
The runner remains Offline after registration.
Likely causeThe process never started, outbound HTTPS or long-poll connectivity is blocked, the registration expired, system time is wrong, or the runner registered at a different scope/group.
Inspect systemd state, the runner _diag logs, UTC/time synchronization, DNS, and the runner entry in organization settings without printing tokens.Compare the approved organization URL, group, labels, and network allowlist with the effective host configuration.
ResolutionRepair the exact service, DNS, time, or egress dependency and register a fresh ephemeral instance when the old registration is uncertain. Do not expose the registration token in a connectivity test.
A job stays queued until the 24-hour timeout.
Likely causeNo online runner matches every requested label and group, the repository lacks access to the group, capacity is exhausted, or the autoscaler failed to create a JIT runner.
Compare the workflow runs-on group/labels with runner-group repository access and the online runner inventory.Inspect autoscaler provisioning and JIT API status without logging encoded configuration or API credentials.
ResolutionCorrect routing policy or restore autoscaler capacity. Do not broaden the default runner group or remove labels merely to drain the queue.
A pull request from a fork is scheduled on the trusted self-hosted runner.
Likely causeThe workflow trigger/routing condition is too broad, the runner group includes a public repository, or pull_request_target checks out and executes untrusted code.
Cancel the job before execution, preserve the workflow event and routing decision, and inspect repository/group access plus the exact checked-out ref.Assume the host and reachable credentials are exposed if any untrusted step began; isolate it before collecting evidence.
ResolutionRoute fork and untrusted pull-request tests to GitHub-hosted or a disposable unprivileged pool, restrict the trusted group to approved repositories/events, rotate potentially exposed credentials, and rebuild the runner.
A completed ephemeral runner remains online and accepts another job.
Likely causeIt was registered as persistent, the ephemeral flag was omitted, a service restarted stale state, or the autoscaler reused a disk/registration.
Inspect the runner record's ephemeral property, local .runner metadata, process/service state, and job count.Check orchestrator teardown logs and image identity without starting the instance again.
ResolutionDisable the runner, destroy the instance and work volume, remove the stale registration, and fix the factory so every instance is one-job JIT or ephemeral before returning capacity.
The runner cannot update and GitHub stops assigning jobs.
Likely causeAutomatic updates were disabled but the immutable image was not rebuilt within 30 days, or GitHub marked a critical update as required.
Compare the installed runner version with the current supported release and GitHub release/security notices.Inspect autoscaler image digest and rollout age; do not enable arbitrary network downloads on old production instances.
ResolutionBuild, verify, canary, and roll out a new immutable runner image immediately. Keep the old pool disabled; GitHub may refuse jobs for outdated runners.
A job modifies the host despite running as the runner user.
Likely causeThe account has sudo, belongs to docker/libvirt/lxd groups, has writable service/config paths, or can reach a root-equivalent management socket.
Isolate the host, inspect identity/group membership, sudo policy, capabilities, sockets, mounts, and system changes from trusted forensics.Treat Docker socket access as host-root capability and review every persistent path across jobs.
ResolutionRebuild from a trusted image, remove root-equivalent memberships/sockets, use a separate isolated builder architecture when privileged build capability is required, and rotate reachable credentials.
Secrets appear in job logs or diagnostic bundles.
Likely causeA workflow echoed environment/context data, debug logging was enabled, a third-party action exfiltrated values, or masking did not recognize transformed secret content.
Restrict log access, identify the exact job/action/step and secret scope, and preserve minimal evidence without downloading logs broadly.Review workflow permissions, action pins, environment approvals, shell tracing, and outbound destinations.
ResolutionRevoke and rotate exposed credentials, remove the logging/exfiltration path, minimize token permissions, and rebuild the runner. Masking is not a substitute for not emitting secrets.
An action tag resolves to unexpected code.
Likely causeThe workflow pins a mutable tag/branch rather than a full commit SHA, or repository governance allowed an unreviewed dependency update.
Resolve the action reference to a commit through GitHub and compare it with the approved dependency record.Review Dependabot/update PR, publisher verification, release notes, and source diff before rerunning.
ResolutionPin the action to a reviewed full commit SHA, record the human-readable version in a comment, and require review for future pin changes.
The runner cannot reach a required internal service.
Likely causeEgress/microsegmentation intentionally excludes it, DNS or certificate trust is wrong, or the workflow was routed to the wrong trust pool.
Confirm the job's approved dataflow and runner group, then test DNS/TLS/network reachability to only the named endpoint.Inspect proxy and certificate configuration without weakening verification or printing authorization headers.
ResolutionAdd the narrow reviewed service path to the correct runner class or redesign the job. Do not attach the general runner to a broad production network.
External log shipping stops for ephemeral runners.
Likely causeThe collector endpoint, credentials, buffering, or teardown order failed, and the VM was destroyed before _diag logs left the host.
Inspect collector health, last received runner ID/job correlation, queue metrics, and orchestration teardown timing.Run a synthetic one-job canary that produces a known nonsecret diagnostic marker.
ResolutionBlock production autoscaling acceptance until logs are forwarded and acknowledged before destruction, with bounded local buffering and monitored loss.
Disk, inode, or work-directory usage grows across jobs.
Likely causeThe runner is persistent, teardown does not destroy the work volume, build caches are unbounded, or a post-job hook failed.
Compare instance age/job count, mount identity, filesystem use, and autoscaler deletion events.Inspect a canary before and after one job; do not delete evidence on a potentially compromised runner.
ResolutionRetire the affected host and restore one-job immutable replacement behavior. Treat cleanup scripts as defense in depth, not a substitute for destruction.
The proposed rollback re-enables a shared persistent runner.
Likely causeCapacity pressure overrode the security boundary or the team lacks a known-good runner image and GitHub-hosted fallback.
Identify which repositories, secrets, and networks the persistent runner could access and whether untrusted jobs ever touched it.Review the last known-good image, update status, group policy, and hosted-runner fallback.
ResolutionKeep the questionable runner disabled, restore capacity from a verified immutable image or approved hosted pool, and investigate rather than reusing cross-job state.
Reference
Frequently asked questions
Is --ephemeral enough to clean a runner?
No. GitHub deregisters it after one job, but the infrastructure owner must destroy the machine and work storage. Cleanup scripts alone cannot prove hostile persistence is gone.
Can private-repository pull requests be trusted?
Not automatically. Anyone able to fork and open a pull request can potentially influence executed code. Route such jobs away from the trusted runner until reviewed code reaches a trusted event/ref.
Can I disable automatic runner updates?
Yes for immutable-image management, but GitHub requires updates within 30 days of a release and may block jobs immediately for a critical security update.
Why are labels not a security boundary?
Labels route jobs by declared characteristics. Runner groups and repository access policy decide who is allowed to use the infrastructure.
Recovery
Rollback
Disable the runner group and autoscaler first; do not fall back to a shared persistent runner. Restore capacity from the previous verified immutable image or GitHub-hosted runners, using the same repository/event restrictions. Any runner that executed unexpected code, missed teardown, or used a suspect image is destroyed and its reachable credentials rotated.
- Restrict or disable runner-group repository access and pause the autoscaler without deleting organization audit evidence.
- Allow queued jobs to use only an explicitly approved GitHub-hosted fallback; cancel jobs that require private capabilities.
- Isolate and destroy suspect instances/work disks after external evidence is preserved; remove stale runner records.
- Rotate registration/JIT controller credentials plus every repository, environment, registry, cloud, package, and internal identity the runner could reach.
- Rebuild the last known-good signed runner image with a currently supported runner release rather than restarting an old persistent host.
- Repeat trusted, fork, unauthorized repository, mutable action, one-job, log acknowledgment, and disk-destruction tests before reopening.
Evidence