Self-hosted GitHub Actions runner using Podman Compose (rootless). It is a
port of an equivalent Forgejo Actions runner setup, so the service topology
(dind sidecar + binfmt one-shot + self-registering runner) deliberately
mirrors that shape:
- Docker-in-Docker (DinD): a real
dockerdsidecar so CI workflows can usedocker build,docker buildx,docker/build-push-action, etc. - Multi-arch builds: QEMU binfmt handlers registered inside
dockerdat startup, enablinglinux/amd64andlinux/arm64image builds on a single host. - Auto-registering runner: no manual
config.sh/run.shdance — the runner container registers itself against GitHub on startup using a PAT.
# Fedora/RHEL
sudo dnf install podman podman-compose
# Ubuntu/Debian
sudo apt install podman podman-composeRootless Podman — no daemon or group membership needed. The dind sidecar runs
privileged: true inside your rootless user namespace, so it is confined to your
own UID on the host. If the runner hits permission errors on its data volume on
first make start, remap ownership into the user namespace:
podman unshare chown -R 0:0 ~/.gh-runners/r1/dataCreate it under Settings → Developer settings → Personal access tokens. The
token goes in .env (gitignored) as ACCESS_TOKEN — never commit it, and never
paste it into .env.example.
Store it in whatever secret manager you already use and pull it in at setup time rather than keeping it in shell history. With the 1Password CLI, for example:
op read "op://<vault>/<item>/<field>" >/dev/null # verify the reference resolvesRequired scopes:
- Repo-scoped runner (
RUNNER_SCOPE=repo): classic PAT withreposcope, or a fine-grained PAT with "Administration: Read & write" on the target repo. - Org-scoped runner (
RUNNER_SCOPE=org): classic PAT withadmin:orgscope.
make setupThis creates ~/.gh-runners/r1/data (the runner's work directory, mounted
into the container) and copies .env.example to .env if it doesn't exist
yet.
Edit .env and set three things:
ACCESS_TOKEN— the PAT from step 1.- The target —
RUNNER_SCOPE=repo+REPO_URL, orRUNNER_SCOPE=org+ORG_NAME. They are a pair; set both halves of exactly one. LABELS— must match what your workflows already put inruns-on:. A mismatch fails silently: the runner registers fine and jobs queue forever waiting on a runner that never matches.
make startOn first start the binfmt service registers QEMU for all architectures
inside dockerd, then exits. The runner registers itself against GitHub
automatically using ACCESS_TOKEN.
Check it's online: repo/org Settings → Actions → Runners.
make start # start all services
make stop # stop all services
make logs # follow runner logs
make restart # restart runner after config change
make setup-multiarch # re-register QEMU binfmts (if dind restarted)
make hooks # install the git pre-commit hook (once per clone)
make scan # full-history secret sweep (gitleaks)Note: make restart reuses the existing containers, so it does not pick up
changes to the resource limits or any other docker-compose.yml field — those
need make stop && make start to recreate the containers.
Scale to multiple concurrent runners (see .env.example's Scaling section —
leave RUNNER_NAME unset when doing this):
make start RUNNER_COUNT=2The runner image (myoung34/github-runner) calls the GitHub API on container
start using ACCESS_TOKEN to mint a short-lived registration token, then
registers and runs the GitHub Actions runner agent — no manual config.sh
step required. This is the auto-registration analog of the Forgejo side,
where forgejo-runner:12 self-registers from the uuid + token baked into
config.yaml.
DOCKER_HOST=tcp://dind:2375 is set on the runner container, pointing at
the dind sidecar (its own real dockerd, not the host's). Any docker
CLI invocation inside a job step — a raw docker build ... shell command, or
an action like docker/build-push-action — picks up DOCKER_HOST from the
runner process's environment and talks to that sidecar daemon instead of
requiring privileged access to the host's own Docker socket. This mirrors the
Forgejo runner's container.docker_host: tcp://dind:2375 config exactly.
Set LABELS in .env. The shipped default is:
self-hosted, linux, x64, dind
Reference it in a workflow:
jobs:
build:
runs-on: [self-hosted, linux, x64, dind]Add your own project-specific label if you run several runner sets against the same org and want workflows to pin one of them.
Job containers are created by the dind daemon and land in dind's own cgroup
subtree, so the limits on the dind service bound the entire job fleet
collectively. There is no per-job knob on this side — a workflow that wants a
tighter budget sets it in its own container.options.
| service | cpus | memory | pids |
|---|---|---|---|
dind |
8 | 32G | 8192 |
runner |
2 | 4G | 1024 |
Defaults are sized for a 24-core / 62 GiB host. Tune them in
docker-compose.yml to leave your desktop enough headroom; without any limit a
single test suite can saturate the machine.
Two caveats:
memoryis a hard cap, not a throttle — breaching it OOM-killsdockerdand takes any in-flight job with it. Leave real headroom.- The
runnerlimits apply per replica, somake start RUNNER_COUNT=4reserves 4× those numbers.
Rootless enforcement needs cgroup-v2 delegation. Verify:
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/cgroup.controllers
# want: cpu io memory pidsmake hooks # pre-commit installHooks: gitleaks secret scanning,
prettier for yaml/json/markdown, plus the standard
pre-commit-hooks whitespace/yaml/private-key checks. pre-commit provisions
everything itself — no machine-wide gitleaks or node install needed.
The gitleaks hook only sees the staged diff. make scan sweeps full history.
docker-compose.yml # Service definitions: dind, externals-sync, binfmt, runner
.env.example # Config template — copy to .env
Makefile # Convenience targets
AGENTS.md # Setup/operation guide for coding agents
.pre-commit-config.yaml # gitleaks secret scan + formatting hooks
.prettierrc.json # Prettier config (yaml/json/markdown)
.gitignore # Ignores .env + local runner state paths
~/.gh-runners/r1/data/ # Runner work directory — kept outside repo