GitHub’s hosted runners are great until:
- You need to build for a specific distro or kernel version
- Your workflow needs special hardware (GPU, ARM, FPGA)
- Pricing surprises you on month-end
- Your org requires keeping build artifacts on-prem
Then you run self-hosted runners, and you discover the actual challenge: maintaining the runner image. The official actions/runner repo gives you the binary; everything else — OS choice, tool preinstall, ephemeral lifecycle, Docker-in-Docker, credential flow — you build yourself.
The oorabona/github-runner image family is a reusable baseline: three OSs × two flavors with the glue already wired up.
The matrix
| Variant | Compressed (amd64) | Use case |
|---|---|---|
2.334.0 (ubuntu-2404 base) |
289 MB | Default — general workflows on Ubuntu LTS |
2.334.0-dev |
574 MB | Ubuntu + build-essential, Rust toolchain, WebKit deps (Tauri), pkg-config |
2.334.0-debian-trixie |
348 MB | Debian Trixie base (newer packages, shorter EOL cycle) |
2.334.0-debian-trixie-dev |
727 MB | Debian + full build toolchain |
2.334.0-windows-ltsc2022 |
— | Windows Server Core — small footprint, no GUI |
2.334.0-windows-ltsc2022-dev |
— | Full Windows Server — VS Build Tools + SDK (rc.exe needs USER32.dll which only full Server provides) |
base is for “run a workflow on a known OS.” dev is for “build native code that needs compilers and system libs.”
Architecture
Every runner image bundles:
actions/runnerbinary (version matches the tag)runneruser (uid 1001) with sudoers NOPASSWD- Graceful shutdown handler on SIGTERM
- Pre-installed tool cache mount points (
/home/runner/.cargo,/home/runner/.npm, etc.) - Three auth modes (see below)
Linux variants are generated from a single Dockerfile.linux template via generate-dockerfile.sh — distros live in config.yaml so adding a new one means adding 15 lines of YAML, not a whole Dockerfile.
Windows is its own file (Dockerfile.windows) because the Chocolatey-free install path, SHELL switches, and VS Build Tools setup don’t share much with Linux.
Three auth modes
# Mode 1: direct runner registration token (1h expiry, one-shot)
docker run -d --name runner \
-e RUNNER_TOKEN=ABCDEF... \
-e RUNNER_URL=https://github.com/myorg/myrepo \
-e RUNNER_NAME=ci-01 \
--restart unless-stopped \
ghcr.io/oorabona/github-runner:2.334.0
# Mode 2: Personal Access Token (the runner exchanges it for a registration token)
docker run -d --name runner \
-e GITHUB_PAT=ghp_xxx \
-e RUNNER_URL=https://github.com/myorg/myrepo \
ghcr.io/oorabona/github-runner:2.334.0
# Mode 3: GitHub App (best — no long-lived secret, scoped permissions)
docker run -d --name runner \
-e GITHUB_APP_ID=3113413 \
-e GITHUB_APP_INSTALLATION_ID=117019019 \
-e GITHUB_APP_PRIVATE_KEY_PATH=/secrets/app.pem \
-v /path/to/app.pem:/secrets/app.pem:ro \
ghcr.io/oorabona/github-runner:2.334.0
The GitHub App mode mints a JWT with RS256, exchanges it for an installation token, then uses that to get a runner registration token — all before the runner process starts. No secret lives on disk longer than necessary.
Works at both repo scope and org scope — set RUNNER_URL to the org URL for org-wide runners.
Ephemeral + persistent caches
--ephemeral is the right default for self-hosted: each workflow run gets a fresh runner that deregisters itself afterwards. Clean state = no leakage between jobs.
But ephemeral + “fresh every time” = you re-download every cargo dep on every build. Compromise: persistent tool caches mounted from the host:
# compose.yml
services:
runner:
image: ghcr.io/oorabona/github-runner:2.334.0-dev
environment:
RUNNER_TOKEN_FILE: /secrets/token
RUNNER_URL: https://github.com/myorg/myrepo
RUNNER_EPHEMERAL: "true"
RUNNER_TOOL_CACHE: /opt/hostedtoolcache # preserved across runs
volumes:
- /secrets/token:/secrets/token:ro
- tool-cache:/opt/hostedtoolcache
- cargo-cache:/home/runner/.cargo
- npm-cache:/home/runner/.npm
- pnpm-cache:/home/runner/.local/share/pnpm
- /var/run/docker.sock:/var/run/docker.sock # docker-in-docker
restart: unless-stopped
volumes:
tool-cache:
cargo-cache:
npm-cache:
pnpm-cache:
Runner state (ephemeral) + tool downloads (persistent) = fast subsequent builds without dirty cross-run artifacts.
Windows: yes, real Windows
The windows-ltsc2022 variants are real Windows containers (not WSL). The base flavor uses Server Core (smaller, no GUI). The dev flavor uses the full Server image — necessary because rc.exe (Resource Compiler) loads USER32.dll, which requires window-station APIs that Server Core doesn’t ship.
Install flow: no Chocolatey. Everything is direct-download from GitHub releases:
- PowerShell 7 via MSI from PowerShell/PowerShell
- Git for Windows via the official Git-X.Y.Z(.N)-64-bit.exe installer
- jq from jqlang/jq releases (single
.exe)
Chocolatey’s community.chocolatey.org API returns 503 often enough that we just cut it out of the path — the install succeeds the first try, every time.
On the host (Docker Desktop for Windows), the workflow is:
docker run -d `
--name runner-win `
--isolation process `
-e RUNNER_TOKEN="$env:RUNNER_TOKEN" `
-e RUNNER_URL="https://github.com/myorg/myrepo" `
--restart unless-stopped `
ghcr.io/oorabona/github-runner:2.334.0-windows-ltsc2022
Scaling beyond one runner
Three patterns, in order of operational weight:
- Several compose projects on one host — fine for up to ~10 runners. Each runs as
--ephemeral, so concurrency is natural. - Docker Swarm / Kubernetes with actions-runner-controller — ARC auto-scales runner pods based on job queue depth. This image works as the pod image.
- Kubernetes with Runner Scale Sets — the newer ARC model. Same image, different controller.
For a typical org (5–100 runners), Swarm + a simple scale-up script on queue depth handles most of it without pulling in Kubernetes.
Gotchas
- Docker socket mount gives the runner root-equivalent access to the host. Use rootless Docker or sysbox if that matters.
- Windows can’t bind-mount individual files (only directories). Plan your secrets layout accordingly.
ContainerAdminis the default user in Windows containers — some GitHub Actions assume UNIX-style users; setUSER runnerafter tool install if it matters.- Don’t forget
docker system prunein the dev images during build — the VS Build Tools installer leaves hundreds of MBs of cache that shouldn’t ship. - Token rotation — runner registration tokens expire after 1 hour. Use GitHub App mode for anything long-lived.
What’s tracked automatically
Daily upstream monitor picks up:
actions/runnerreleases (PR opens when a new version ships)- PowerShell/PowerShell for Windows
- git-for-windows/git for the Windows Git installer
- jqlang/jq for jq
- Base image tags (ubuntu:24.04, debian:trixie, Windows Server Core/Server)
Minor/patch auto-merges; majors wait for review. If a new runner version breaks something in your workflows, pin the tag — every version stays in GHCR and Docker Hub with retention = 3.
TL;DR
# Ubuntu, general use
docker pull ghcr.io/oorabona/github-runner:2.334.0 # 289 MB
# Ubuntu + build tools
docker pull ghcr.io/oorabona/github-runner:2.334.0-dev # 574 MB
# Debian Trixie variant
docker pull ghcr.io/oorabona/github-runner:2.334.0-debian-trixie # 348 MB
# Windows
docker pull ghcr.io/oorabona/github-runner:2.334.0-windows-ltsc2022
All variants and auth examples: container dashboard.
If you’re running this in anger, ⭐ the repo — we use the star count to decide when to invest in more niche features like GPU or ARM Windows.