A self-hosted GitHub Actions runner image, plus a Docker Compose setup that runs a fleet of them on anything from a Raspberry Pi 5 to a 64 GB build box.
docker pull ghcr.io/dodi-smart/github-runner:latestThe image builds on myoung34/github-runner and adds:
- the Temurin JDK, the Android SDK (platform, build-tools, platform-tools) and
Node.js LTS, so jobs can skip
setup-java,setup-androidandsetup-node; - the usual CLI tools jobs reach for:
git,git-lfs,jq,yq,gh,zip/unzip,zstd,xxd,rsyncanddocker; rustuponPATHwithout a toolchain: Rust toolchains install on first use (dtolnay/rust-toolchain,rust-toolchain.toml) into the runner's cache and are reused from then on, at no cost to the image size;- multi-arch images (
linux/amd64,linux/arm64), in three variants:slim, the default with the Android SDK, andndk.
The Compose setup adds:
- ephemeral runners: one job per container, then a clean restart;
- warm caches that survive: per-runner caches for bun, npm, pnpm, Gradle, Cargo/rustup, Kotlin/Native, mise, hk, the Dart pub cache and the hosted tool cache;
- one setting to size a host,
RUNNERS=N, plus a script that suggests it; docker run -vand container actions that work, because work dirs sit at the same path on the host and in the runner;- a pruner that keeps every cache within a disk budget sized to the machine, without touching a cache while a job is using it;
- a local action archive cache that avoids GitHub's download rate limits.
- Quick start
- Sizing a host
- Image variants and tags
- How it works
- Using it from workflows
- Caches
- Maintenance
- Action archive cache
- Configuration reference
- Troubleshooting
- Upgrading from named volumes
- Development
You need a Linux host with Docker Engine and the Compose plugin, and admin rights on a GitHub organization.
1. Create a GitHub App that registers the runners. In your organization, go to Settings → Developer settings → GitHub Apps → New GitHub App:
- Organization permissions → Self-hosted runners: Read and write.
- Repository permissions → Contents: Read-only. This is only needed if the action archive cache should read private repositories.
- Turn off the webhook. Install the App on the organization, note its App ID, and generate a private key.
2. Get the Compose files onto the host:
git clone https://github.com/dodi-smart/docker-github-actions-runner-android.git runners
cd runners
cp .env.example .env3. Size the host. The script only reads; it changes nothing. Choose
light for checks, lint and JS builds, or large for Gradle and Android
builds:
hosts/sizing.sh lightPaste its suggestions (RUNNERS, RUNNER_CPUS, RUNNER_MEMORY, and
RUNNER_IMAGE on arm64) into .env.
4. Fill in .env. At minimum set APP_ID, APP_PRIVATE_KEY (the PEM on
one line, with \n for newlines) and APP_LOGIN (the organization). Then set
the labels your workflows will select on, for example LABELS=docker,light.
5. Start the fleet:
docker compose up -d
docker compose logs -f runner # each runner logs the slot it claimedThe runners show up under the organization's Settings → Actions → Runners.
Point a workflow at them with runs-on: [self-hosted, Linux, light].
To update a host later:
git pull && docker compose pull && docker compose up -dEvery host runs the same Compose file. Only .env differs. hosts/sizing.sh
reads the host's CPUs, memory and disk:
- it fits as many runners as both CPU and memory allow for the chosen profile;
lightgives each runner 1 CPU and 3 GiB,largegives 2 CPUs and 6 GiB;- it keeps 2 GiB of memory for the host itself.
Typical results:
| Host | Profile | Suggested .env |
|---|---|---|
| Raspberry Pi 5, 8 GB (arm64) | light | RUNNERS=2, RUNNER_IMAGE=…:slim. The cache budget shrinks with the disk on its own. |
| Hetzner CPX31, 4 vCPU / 8 GB | light | RUNNERS=2, RUNNER_CPUS=1, RUNNER_MEMORY=3g |
| Hetzner CX43, 8 vCPU / 16 GB | light or large | RUNNERS=4 (light) or RUNNERS=2 (large) |
| 64 GB mini PC / build box | large | as many as CPU allows, e.g. RUNNERS=6, RUNNER_CPUS=2, RUNNER_MEMORY=6g |
What the knobs do:
RUNNER_CPUS/RUNNER_MEMORYcap each runner, so one heavy job cannot starve its neighbours. Under a CPU cap the runner also caps Gradle (org.gradle.workers.max), Cargo,makeand CMake parallelism to it. A container otherwise sees every host CPU and spawns a worker for each. Leave both unset, or0, for no limit.- Labels describe capability, not machine names. A host can carry several,
so a big machine labelled
light,largealso picks up light jobs.RUNNER_<n>_LABELSgives runner slot n its own set, for example onelargerunner on an otherwiselighthost. - Coordination runners:
CONTROL_RUNNERS=2adds runners that carry onlyCONTROL_LABELS(defaultcontrol). They have no Docker socket and no caches. Point the short jobs a merge waits on (pickers, required summary jobs) at them, so those never queue behind builds. - Tiny disks: the pruner's budget already scales with the disk. If even
that is too much,
RUNNER_CACHES=offruns without caches.
Images are published to ghcr.io/dodi-smart/github-runner for linux/amd64
and linux/arm64.
| Variant | Contents | Use it for |
|---|---|---|
slim |
JDK, Node, CLI tools; no Android SDK | arm64 hosts (Android's build tools are x86_64-only), JS/JVM-only pools, small disks |
| default | slim + Android SDK (platform, build-tools, platform-tools) |
Android and general CI |
ndk |
default + NDK and CMake | projects with native (C/C++) code |
The Android SDK sits in one layer that is identical across every JDK and Node tag, so a host running several tags downloads it once.
| Tag | Pins |
|---|---|
latest |
nothing: newest JDK + Node LTS, default variant |
jdk21, node22 |
that JDK or Node major |
jdk21-node22 |
both majors |
slim, ndk |
the variant, with the default JDK + Node |
2.338.0-jdk21-sdk37.2-node24 |
everything: runner, JDK, SDK and Node. Never overwritten |
2.338.0-jdk21-sdk37.2-bt37.0.0-node24 |
the same image with build-tools spelled out |
The long tags let a consumer have Renovate derive its Gradle compileSdk and
buildToolsVersion from the image pin, so AGP doesn't download SDK components
on every job.
JDKs: the LTS lines (17, 21, 25) plus the newest GA release. Node: the current
LTS lines. matrix.json is the source of truth. A weekly job
proposes new JDK, Node, SDK, build-tools and NDK versions. Each image carries
RUNNER_IMAGE_TAG and RUNNER_IMAGE_REVISION, so a job can tell what it ran
on.
A project that needs another Android platform installs it in the job, e.g.
sdkmanager "platforms;android-34".
flowchart LR
subgraph host["Host: RUNNER_DATA_DIR (/var/lib/github-runner)"]
s1["slots/1<br/>work/ · cache/"]
s2["slots/2<br/>work/ · cache/"]
sn["slots/N …"]
end
r1["runner container"] -- "flock .lock" --> s1
r2["runner container"] -- "flock .lock" --> s2
p["pruner"] -. "budget, skips busy slots" .-> host
d[("host Docker daemon")]
r1 -- "docker.sock: docker run -v $PWD" --> d
d -- "same path" --> s1
- Slots. Each runner container claims the lowest free slot under
RUNNER_DATA_DIR/slots/and holds a lock on it for its whole life. Its work dir and every cache live in that slot, so no two running jobs ever share a cache. Scaling toRUNNERS=Njust means N slots. - Ephemeral. A runner takes one job, deregisters and exits.
restart: unless-stoppedbrings it back to register afresh./tmpand other leftovers are wiped after each job; the slot's caches stay. - Same path on host and container.
RUNNER_DATA_DIRis mounted at the same path inside the containers. When a job hands a path to the host's Docker daemon (docker run -v "$PWD:/src", a container action, acontainer:job), the daemon finds the same files. - Cache links. The tools' default locations (
~/.gradle,~/.bun,/opt/hostedtoolcache, …) are symlinks into the slot. Workflows don't need any cache configuration. - Self-healing registration.
preflight-entrypoint.shwipes a stored runner registration only after GitHub confirms that runner is gone (404). A deleted runner then registers again instead of crash-looping.
jobs:
build:
runs-on: [self-hosted, Linux, large]
steps:
- uses: actions/checkout@v4
- run: ./gradlew assembleDebug # JDK, SDK and ~/.gradle are warm already- Keep the tools' default cache locations. If a job moves
GRADLE_USER_HOME,BUN_INSTALL_CACHE_DIRorPUB_CACHEinto$RUNNER_TEMP, the warm caches go unused. - Skip the GitHub cache where a runner cache replaces it, e.g.
cache: falseonjdx/mise-actionandsetup-node. Withactions/cacheand the bun cache, use an exact key (norestore-keys), so a bad entry cannot come back. - Docker works. Use
docker build,docker run -v, service containers and container actions as on GitHub-hosted runners. Ports a job's containers publish open on the host, not on the runner'slocalhost. - Jobs run as root. Tool archives unpack root-owned
(
TAR_OPTIONS=--no-same-owner), so git does not refuse a Flutter SDK it unpacked.
Each slot keeps one directory per tool under slots/<n>/cache/:
| Store | Linked from | Evicted as |
|---|---|---|
| bun | ~/.bun |
whole packages; never bin/ |
| npm | ~/.npm |
files (content-addressed) |
| pnpm | ~/.local/share/pnpm, ~/.cache/pnpm |
the whole store |
| gradle | ~/.gradle |
whole dependency versions, transforms, wrapper dists |
| cargo | ~/.cargo |
crates, unpacked sources, git checkouts; never bin/ |
| rustup | ~/.rustup |
whole toolchains; never the default. rustup itself is set up in ~/.cargo on first start |
| konan | ~/.konan |
whole dependencies and prebuilt compilers |
| mise | ~/.local/share/mise, ~/.cache/mise |
whole tool versions; downloads as files |
| hk | ~/.cache/hk |
files |
| pub | ~/.pub-cache |
whole packages |
| toolcache | /opt/hostedtoolcache |
whole tool versions (setup-* actions, Flutter) |
Entries are evicted whole wherever a partial entry would break a build. A package folder missing one file still looks complete to bun.
The pruner service (pruner.sh) keeps slow work off the job
path:
- Cache budget. Each host gets
CACHE_BUDGET_BYTES(default 60 GiB) orCACHE_BUDGET_PCTof the data disk (default 40 %), whichever is smaller, split evenly across the slots. Within a slot, least recently used entries go first, across all stores. - Never under a running job. A slot whose runner is in a job is skipped.
A job that starts while the pruner works on its slot waits for it, up to
PRUNE_WAIT_SECONDS. - Adaptive. It runs daily, and early when free disk drops below
PRUNE_FREE_PCT. Thendocker system prune/docker builder pruneclean old images and build cache, more aggressively when disk is tight. - Hourly: it fills the action archive cache.
After each job, cleanup.sh does a quick tidy-up only: /tmp,
Gradle daemons and locks, and runner logs.
To run a maintenance pass by hand:
docker compose run --rm -e PRUNE_RUN_ONCE=true prunerEphemeral runners download every uses: action for every job. Several
runners behind one IP address soon hit 429 Too Many Requests in "Set up
job".
How the cache avoids that:
- The runner reads archives from
ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE(/opt/actionarchivecache, read-only in every runner) before downloading. - The pruner fills that directory hourly, as the GitHub App. Downloads count against the App's limit, not your IP.
- It fetches every
uses:found inPREFETCH_SOURCES(e.g. the repo that holds your reusable workflows), plusPREFETCH_EXTRA. - A cache miss falls back to a normal download.
To fill it straight away:
docker compose run --rm pruner prefetchTo confirm it works, re-run a job with debug logging. "Set up job" then shows
Copied action archive '/opt/actionarchivecache/...'.
Set these in .env. .env.example has them all, with
comments.
| Variable | Default | Purpose |
|---|---|---|
APP_ID, APP_PRIVATE_KEY, APP_LOGIN |
— | GitHub App and organization (required) |
RUNNERS |
3 |
runners on this host |
RUNNER_DATA_DIR |
/var/lib/github-runner |
host directory for slots (work dirs + caches) |
LABELS |
docker |
labels for every runner |
RUNNER_<n>_LABELS |
— | labels for slot n only |
RUNNER_CPUS, RUNNER_MEMORY |
none | per-runner limits (e.g. 2, 6g) |
RUNNER_CACHES |
on |
off runs without caches |
CONTROL_RUNNERS, CONTROL_LABELS |
0, control |
coordination runners |
RUNNER_IMAGE |
ghcr.io/dodi-smart/github-runner |
image or tag (e.g. :slim) |
RUNNER_NAME_PREFIX, RUNNER_GROUP |
runner, Default |
runner name and group |
CACHE_BUDGET_BYTES, CACHE_BUDGET_PCT |
60 GiB, 40 |
pruner cache budget per host |
PRUNE_FREE_PCT, PRUNE_INTERVAL_SECONDS |
15, 86400 |
when the pruner runs |
PREFETCH_SOURCES, PREFETCH_EXTRA |
— | action archive cache sources |
DISABLE_AUTO_UPDATE |
empty | any non-empty value (even false) disables runner self-update |
EPHEMERAL and DISABLE_AUTO_UPDATE count as set for any non-empty value,
because the upstream entrypoint only checks whether they are empty.
- A runner restarts in a loop. Run
docker compose logs runner. Check the App ID, the key (one line,\nescapes) and that the App is installed onAPP_LOGINwith Self-hosted runners: Read and write. - Which slot is a runner on? The first log line says
[preflight] slot <n> (...). Inside a job,$RUNNER_SLOTand$RUNNER_SLOT_DIRsay the same. - The disk fills up. Lower
CACHE_BUDGET_BYTES, then run a maintenance pass by hand (see Maintenance).du -sh $RUNNER_DATA_DIR/slots/*/cache/*shows what is big. - A job fails with a corrupt cache. Stop that slot's runner, delete the
store (
rm -rf $RUNNER_DATA_DIR/slots/<n>/cache/<store>) and start it again. It refills on the next job. - Don't put
RUNNER_DATA_DIRon NFS. Slot locks useflock, which needs a local filesystem.
Earlier versions used three hard-coded runner services, each with its own named volumes. To move to slots:
git pull
docker compose down
# set RUNNERS (and CONTROL_RUNNERS=2 if you used COMPOSE_PROFILES=control) in .env
docker compose up -dWhat changes:
- Caches start cold once and warm up within a few jobs.
- The old volumes stay until you delete them with
docker volume ls -q | grep -E '_runner-(work|bun|npm|pnpm|gradle|cargo|rustup|konan|mise|hk)' | xargs docker volume rm. COMPOSE_PROFILES=controlandhosts/no-caches.compose.ymlare gone, replaced byCONTROL_RUNNERSandRUNNER_CACHES=off.
build.ymlbuilds and smoke-tests every pull request and runs the script tests. ItsAll builds passedcheck is the one to require.deploy.ymlpublishes the images frommain.- Renovate keeps the base image and the actions current (
forkProcessingis on: this repo is a fork of compscidr/docker-github-actions-runner-android).
Build and test locally:
docker build --target runner --build-arg JAVA_VERSION=21 --build-arg NODE_VERSION=24 .
docker run --rm -v "$PWD:/w" -w /w ubuntu:24.04 bash -c 'for t in tests/*.sh; do bash "$t"; done'
shellcheck -x *.sh hosts/*.sh tests/*.shThe build args and their defaults are at the top of the
Dockerfile.