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. - Other workloads stay. On a host that also runs other Docker work, list
what to keep in
DOCKER_PRUNE_KEEP_LABELSandDOCKER_PRUNE_KEEP_IMAGES. Each full pass logs every kept image with its size, so you can see why disk space did not come back. Both need Docker 25 or later on the host; on an older one the pass logs that and skips the docker prune. Docker's age limit never counts from last use, so an idle image without protection can go soon after its container stops. Networks and build cache are not protected: a kept stopped container on a custom network needsdocker compose up, notdocker start. - 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 |
5, 86400 |
when the pruner runs |
DOCKER_PRUNE_KEEP_LABELS |
empty | space-separated key or key=value: images and stopped containers the pruner keeps, plus the image of a kept container |
DOCKER_PRUNE_KEEP_IMAGES |
empty | space-separated name patterns, e.g. variscite:* ghcr.io/acme/*, for images you cannot label |
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.