Chapter 09Lesson 02~210 minutes

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.

Hands-onDisposable VMRegistrationResidual stateDeregistration

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

Do not use your daily workstation, production server, developer laptop with SSH keys, or a machine that can reach sensitive infrastructure.

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

  1. Run with mode=create. Record run ID/attempt/SHA and runner name.
  2. Run again with mode=inspect. Predict residual_state=true and verify it. This proves the host is not automatically reset between runs.
  3. Run with mode=clean. Verify the directory is gone.
  4. On the VM, inspect _diag filenames 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.

Next lesson

Design choices and trust boundaries

Compare persistent versus ephemeral/JIT runners, repository versus organization scope, VM/container/bare-metal isolation, group authorization and update strategies.

Knowledge check

Why does the lab use foreground run.sh instead of installing a service?

What does the second inspect run prove?

Why must deregistration and VM deletion both be verified?

Where should the real registration token be stored?

What is missing if a team relies only on a production label?

Official references and version notes

Version and compatibility 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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.