Self-Hosted Runners, Runner Groups, Isolation, and Operational Security: Guided Hands-On Workflow
This guided lab uses a disposable private repository and a disposable local VM or equivalent throwaway host. You will register one repository-scoped runner, start it in foreground mode, route a harmless manual workflow to it, prove that host state can survive a workflow run, clean that state, preserve selected diagnostics, and deregister the runner. A simulation-only path is included if you cannot safely provision disposable compute.
Learning objectives
- Prepare a disposable runner host and repository with explicit authorization and abort conditions.
- Register a repository-scoped runner without exposing its short-lived registration token.
- Inspect runner routing, foreground/service state and version before executing a trusted job.
- Demonstrate and then remove controlled residual state outside the GitHub workspace.
- Deregister the runner, preserve reviewed evidence and wipe the disposable lab state.
1. Mandatory safety boundary
Use a disposable local VM, throwaway machine or equivalent clean
environment. The lab repository should be private and disposable.
The workflow uses no repository secret, no cloud credential, no
Docker socket and permissions: {}.
If you cannot provision disposable compute, use the simulation path in section 10. It teaches the state/routing model without connecting a runner to GitHub.
2. Preflight record
-
Repository: learner-owned disposable private repository such as
gha-runner-lab. - Host: fresh local VM snapshot with no private keys, browser profiles or production mounts.
-
User: dedicated unprivileged account such as
gha-lab; not root/admin for job execution. - Network: outbound HTTPS to GitHub only plus normal package/network requirements; no sensitive internal routes.
- Credentials: only GitHub’s short-lived runner registration/removal token, entered locally and never stored in workflow YAML.
- Abort: stop if the runner appears in any other repository/org scope, if unexpected secrets exist, or if the host has sensitive network access.
3. Download the account-appropriate runner package
In the disposable repository, open Settings → Actions → Runners → New self-hosted runner. Choose the VM’s OS and architecture and use the download instructions shown there. This is better than copying a hard-coded release URL because runner releases roll out progressively.
# After extracting the runner package inside the disposable VM:
./run.sh --version
pwd
ls -la | sed -n '1,20p'
Record the displayed runner version and the SHA-256 of the downloaded package in your evidence notes. Do not treat the public “latest release” alone as proof of what your account should run.
4. Register without leaking the time-limited token
Use the repository URL and registration token generated by GitHub. Do not paste the real token into notes, screenshots, shell scripts or workflow files. On Linux, read it without echoing:
read -rsp 'Runner registration token: ' RUNNER_REG_TOKEN; echo
./config.sh \
--url https://github.com/OWNER/gha-runner-lab \
--token "$RUNNER_REG_TOKEN" \
--name ch09-lab-01 \
--labels chapter09-lab \
--work _work \
--unattended
unset RUNNER_REG_TOKEN
GitHub automatically adds default labels such as
self-hosted, OS and architecture unless you explicitly
disable them. Confirm in repository Settings that the runner is
repository-scoped and shows the expected labels.
5. Start in foreground mode for the lab
./run.sh
Foreground mode avoids installing a privileged system service for a
one-chapter experiment. In another terminal, inspect the process
using read-only commands such as
ps -ef | grep Runner.Listener. The GitHub runner page
should transition to Idle while it waits for work.
For production Linux service mode, GitHub provides
svc.sh; install it under a dedicated service user
rather than defaulting every job to root. Service mode is optional
here. If you do choose it in the disposable VM, inspect rather than
guess its state:
sudo ./svc.sh status
systemctl --type=service --all --no-pager | grep 'actions.runner.' || true
6. Harmless residual-state workflow
Create .github/workflows/ch09-runner-state.yml in the
disposable repository:
name: chapter09-runner-state
on:
workflow_dispatch:
inputs:
mode:
description: Controlled state operation
required: true
type: choice
options: [create, inspect, clean]
permissions: {}
jobs:
state:
runs-on: [self-hosted, linux, x64, chapter09-lab]
steps:
- name: Safe runner identity
shell: bash
run: |
set -euo pipefail
printf 'runner_name=%s\n' '${{ runner.name }}'
printf 'runner_os=%s\n' '${{ runner.os }}'
printf 'runner_arch=%s\n' '${{ runner.arch }}'
printf 'run_id=%s attempt=%s\n' "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT"
printf 'sha=%s\n' "$GITHUB_SHA"
id
- name: Create controlled residual file
if: inputs.mode == 'create'
shell: bash
run: |
set -euo pipefail
state="$HOME/.gha-ch09-lab"
mkdir -p "$state"
printf '%s\n' "$GITHUB_RUN_ID" > "$state/sentinel.txt"
printf 'created=%s\n' "$state/sentinel.txt"
- name: Inspect controlled residual file
if: inputs.mode == 'inspect'
shell: bash
run: |
set -euo pipefail
state="$HOME/.gha-ch09-lab/sentinel.txt"
if test -f "$state"; then
echo 'residual_state=true'
wc -c "$state"
else
echo 'residual_state=false'
fi
- name: Clean controlled residual file
if: inputs.mode == 'clean'
shell: bash
run: |
set -euo pipefail
rm -rf "$HOME/.gha-ch09-lab"
test ! -e "$HOME/.gha-ch09-lab"
The workflow never reads source code, secrets or the entire environment. The only persistent state is a synthetic sentinel inside the dedicated lab user’s home directory.
7. Execute and reconcile three runs
-
Run with
mode=create. Record run ID/attempt/SHA and runner name. -
Run again with
mode=inspect. Predictresidual_state=trueand verify it. This proves the host is not automatically reset between runs. -
Run with
mode=clean. Verify the directory is gone. -
On the VM, inspect
_diagfilenames and the lab user home. Do not upload or print credential files.
This is a controlled demonstration of persistence—not a claim that GitHub’s workspace cleanup always fails. The point is that the host contains state outside workflow-owned cleanup boundaries.
8. Preserve reviewed diagnostics before teardown
mkdir -p "$HOME/ch09-evidence/diag"
cp _diag/Runner_*.log "$HOME/ch09-evidence/diag/" 2>/dev/null || true
cp _diag/Worker_*.log "$HOME/ch09-evidence/diag/" 2>/dev/null || true
sha256sum "$HOME"/ch09-evidence/diag/* 2>/dev/null || true
Review logs for sensitive content before moving them anywhere. Production ephemeral runners should forward diagnostics to external storage automatically rather than relying on a VM that is about to be destroyed.
9. Deregister and wipe
Stop run.sh. In repository Settings, start the
runner-removal flow and use GitHub’s generated removal command/token
locally. A typical Linux command is:
read -rsp 'Runner removal token: ' RUNNER_REMOVE_TOKEN; echo
./config.sh remove --token "$RUNNER_REMOVE_TOKEN"
unset RUNNER_REMOVE_TOKEN
Verify the runner disappears from repository Settings. Then retain only reviewed evidence, delete the runner installation directory and destroy/revert the disposable VM. Deregistration removes GitHub routing identity; deleting the VM removes local persistence. They are separate controls.
10. Free/offline simulation path
If you cannot safely register a real runner, model the lifecycle with synthetic files:
root="$HOME/gha-ch09-sim"
mkdir -p "$root/_work" "$root/_diag" "$root/home"
printf '{"name":"ch09-lab-01","scope":"repo","status":"Idle","labels":["self-hosted","linux","x64","chapter09-lab"]}\n' > "$root/runner-inventory.json"
printf 'simulated-run-1001\n' > "$root/home/sentinel.txt"
test -f "$root/home/sentinel.txt" && echo 'residual_state=true'
rm -f "$root/home/sentinel.txt"
test ! -e "$root/home/sentinel.txt"
rm -rf "$root"
This does not prove GitHub routing, but it faithfully teaches the state boundaries and cleanup evidence without credentials, billing or privileged infrastructure.
11. Small challenge
A team wants to add the label production to a shared
organization runner and assumes that only the deployment repository
can then use it. Identify the missing control. The answer is not
another label: use runner-group repository/workflow access policy
and a dedicated trust boundary.
Knowledge check
Why does the lab use foreground run.sh instead of
installing a service?
It avoids adding unnecessary host-wide service configuration for a disposable experiment while still exposing runner process/routing state.
What does the second inspect run prove?
A controlled file in the persistent service-account home can survive between workflow runs on the same self-hosted host.
Why must deregistration and VM deletion both be verified?
Deregistration removes GitHub routing identity; wiping/reverting the host removes local files, processes, caches and other persistent state.
Where should the real registration token be stored?
Only transiently in the local registration flow. It should not be placed in workflow YAML, documentation, logs or committed scripts.
What is missing if a team relies only on a
production label?
Authorization. Use runner scope/group access policy to restrict which repositories/workflows can target the runner.
Official references and version notes
- Self-hosted runners concept — current responsibility boundary, hierarchy scopes, persistence model and maintenance ownership.
- Self-hosted runners reference — current supported operating systems/architectures, routing, communication, ephemeral/JIT guidance and update requirements.
- Adding self-hosted runners — current repository/organization/enterprise registration workflow and time-limited registration-token process.
-
Using self-hosted runners in a workflow
— current
runs-onlabel/group selection semantics. - Runner groups — runner groups as access-control boundaries and plan-dependent organization features.
- Managing access to self-hosted runners using groups — selected repository/workflow access policies and group governance.
- Secure use reference — current self-hosted runner hardening, public-repository warning, JIT guidance and trust-boundary risks.
- Compromised runners — current impact model for malicious workflow code, secrets, tokens and cross-repository credentials.
-
Monitoring and troubleshooting self-hosted runners
— current runner status,
_diagRunner/Worker logs and update diagnostics. - Configuring the runner as a service — current Linux/macOS/Windows service-mode procedures and status checks.
- Removing self-hosted runners — current deregistration/offline behavior, automatic stale-runner removal and local cleanup guidance.
- Actions Runner releases — public runner release history and progressive-release note.
Version-sensitive self-hosted-runner behavior was rechecked
against current primary GitHub documentation on
2026-09-09. The latest public
actions/runner release visible at verification time
is v2.337.0, released through a progressive
rollout; the repository/organization “New self-hosted runner” page
remains the authoritative version/download instruction for the
specific account. The current reference lists x64 support on
Linux/macOS/Windows, Arm64 on Linux/macOS/Windows in public
preview, and Arm32 on Linux. GitHub currently lists Ubuntu 20.04+,
Debian 10+, several other Linux families, Windows 10/11 and
Windows Server 2016/2019/2022, and macOS 11+ as supported runner
hosts. Self-hosted runners connect outbound to GitHub over HTTPS
port 443 and must remain able to reach the documented Actions
domains; no inbound job-listener port is required. By default the
runner application self-updates, but the operating system and all
other host software remain the operator’s responsibility. If
automatic runner updates are disabled, the runner must be updated
within 30 days of a new available release; critical security
updates can block new jobs until applied. GitHub recommends
ephemeral self-hosted runners for autoscaling and warns that
ephemeral/JIT runner logs should be forwarded to external storage
before production use. Runner groups can restrict
repository/workflow access; labels select capabilities but are not
an authorization boundary. The actual registration path is
optional and must use a disposable private repository/host. The
simulation path requires no GitHub credential or paid feature.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.