Based off https://github.com/myoung34/docker-github-actions-runner but adds the android sdk into the container so we don't need to run the setup-android action every time from: https://github.com/android-actions/setup-android
The Android SDK requires Java, so the container includes a Temurin JDK as well, which means you can also skip the setup-java action: https://github.com/actions/setup-java
Node.js LTS is also baked in (see matrix.json for available versions), so
actions/setup-node is unnecessary for LTS workflows.
Images are published as multi-arch manifests for both linux/amd64 and
linux/arm64, so Apple Silicon Macs (via Docker Desktop / OrbStack) and ARM
Linux hosts run natively without emulation.
To run the container, check out the environment variables from the base image: https://github.com/myoung34/docker-github-actions-runner#environment-variables
docker-compose.yml is the X64 beelink layout — three
persistent runners, each with its own work and cache volumes — not a
side file. After pulling a new image on the host:
docker compose pull && docker compose up -d
Existing named runner-work* volumes keep their data; new cache volumes
start empty and warm on first jobs. Do not share one bun/gradle volume
across the three containers: concurrent jobs would corrupt the same tree.
This compose is for the X64 class (beelink, docker, large). System
labels (Linux, X64) are still applied by the runner binary; set
LABELS in .env to beelink,docker,large (do not add a build label).
EPHEMERAL stays false so the volumes survive between jobs.
Each runner gets its own:
| Mount | Volume (runner / runner-2 / runner-3) | Why |
|---|---|---|
/tmp/runner/work |
runner-work* |
Job workspace. Cleanup spares $RUNNER_WORKDIR. |
/root/.bun |
runner-bun* |
Bun install cache |
/root/.npm |
runner-npm* |
npm cache |
/root/.cache/pnpm |
runner-pnpm* |
pnpm store |
/root/.gradle |
runner-gradle* |
Gradle modules, build-cache, transforms |
/root/.cargo |
runner-cargo* |
Cargo registry |
Budget ~20 GB per runner (enforced by the post-job hook with LRU, not a
wipe), ~60 GB per beelink. ARM64 / Pi hosts should not mount these
cache volumes — those runners isolate to RUNNER_TEMP.
Do not tmpfs /tmp. RUNNER_WORKDIR is /tmp/runner/work; a tmpfs
or a blanket find /tmp -delete hides or wipes the work volume.
Named volumes are unused if the job relocates the caches. On these X64
self-hosted runners, workflows must not isolate GRADLE_USER_HOME or
BUN_INSTALL_CACHE_DIR (or npm_config_cache / CARGO_HOME) to
$RUNNER_TEMP. That is the setup-stack local-home mode in
dodi-smart/.github — deps-verify stays isolated; pr-checks uses the
default homes so the volumes are actually read.
The other half of cache poison is GitHub actions/cache with
restore-keys re-importing a partial bun tarball. Use an exact key, or
do not upload the bun install cache at all.
Images are published to GitHub Container Registry (GHCR):
ghcr.io/dodi-smart/github-runner
Each image is built with a specific combination of base runner version, JDK version, Node major version, and Android SDK level. Tags let you choose how tightly to pin:
| Tag | Example | What it pins | What floats |
|---|---|---|---|
latest |
latest |
Nothing | Runner, JDK, Node, and SDK all track latest |
jdk<VERSION> |
jdk21 |
JDK major version | Runner, Node (default), and SDK track latest |
node<VERSION> |
node22 |
Node major version | Runner, JDK (default), and SDK track latest |
jdk<JDK>-node<NODE> |
jdk21-node22 |
JDK + Node | Runner and SDK track latest |
<RUNNER>-jdk<JDK>-sdk<SDK>-node<NODE> |
2.332.0-jdk21-sdk36-node22 |
Everything | Nothing — fully pinned |
latest— always the newest runner, default JDK, default Node LTS, and latest Android SDK. Good for staying current, but builds may break if a new SDK introduces incompatibilities.jdk21/jdk17— pin the JDK version but still get runner, Node, and SDK updates automatically. A good balance for most users.jdk21-node22— pin both JDK and Node majors; runner and SDK still track latest. Useful when an LTS-pinned Node is part of your build's contract.2.332.0-jdk21-sdk36-node22— fully pinned. Use this for reproducible builds where you want complete control over when to upgrade. Old pinned tags are never overwritten; they remain in the registry from previous builds.
Every image includes:
- The myoung34/github-runner base image
- Temurin JDK (the version in the tag)
- Node.js LTS (the version in the tag) — installed from NodeSource, replacing the base image's older system Node
- Android SDK platform matching the SDK level in the tag (e.g.,
sdk36=platforms;android-36) - Latest Android build-tools and NDK for that SDK level
- Android SDK command-line tools, platform-tools, and cmake
- Multi-arch manifest covering
linux/amd64andlinux/arm64
Projects that need additional SDK platforms (e.g., an older compileSdk) can
install them at build time:
- run: sdkmanager "platforms;android-28"The build matrix is defined in matrix.json. Check that file
for the current list of JDK versions and which one is the default.
Also defined in matrix.json — see node_versions for the
current LTS majors and default_node for the version applied to short tags
like latest and jdk<VERSION>.
Versions are kept up to date automatically:
| Component | Mechanism |
|---|---|
| Base runner image | Renovate tracks myoung34/github-runner Docker tags |
| GitHub Actions versions | Renovate (config:recommended) |
| New JDK major versions | Weekly workflow queries the Adoptium API and opens a PR |
| New Node LTS majors | Weekly workflow queries the Node Release schedule and opens a PR |
| Android SDK platform level | Weekly workflow queries sdkmanager and opens a PR |
| Android build-tools | Weekly workflow queries sdkmanager and opens a PR |
| Android NDK | Weekly workflow queries sdkmanager and opens a PR |
Every pull request is validated by build.yml,
which builds each JDK against the default Node, each Node against the default
JDK, and one linux/arm64 build of the default pair. Its All builds passed
job is the single check to require in branch protection.
Because this repository is a fork, renovate.json sets
"forkProcessing": "enabled". The Mend-hosted Renovate App leaves fork
processing disabled for org-wide ("All repositories") installations, so without
that flag Renovate silently skips the repository and the base image is never
bumped.
The weekly SDK/JDK/Node workflow opens its PR with GITHUB_TOKEN, which means
GitHub does not run build.yml on it. Upstream solves this with an
AUTOMERGE_PAT secret and gh pr merge --auto; this fork does not, so those
PRs are merged manually and the builds run on merge.
The image wraps the base entrypoint with
preflight-entrypoint.sh. When
CONFIGURED_ACTIONS_RUNNER_FILES_DIR is set, the upstream entrypoint reuses
persisted registration credentials without checking them, so a runner that was
removed on GitHub's side crash-loops forever instead of re-registering. The
preflight asks GitHub whether the persisted runner still exists and wipes the
stored registration only on a definitive 404, letting a fresh ACCESS_TOKEN
registration take over. Timeouts, missing variables, and unparseable state all
leave the configuration untouched.
docker build -f Dockerfile .
Build args can be used to customize the image. See the top of the
Dockerfile for the current defaults and available args
(VERSION, JAVA_VERSION, NODE_VERSION, COMPILE_SDK, BUILD_TOOLS,
NDK_VERSION, SDK_TOOLS).
Example:
docker build --build-arg JAVA_VERSION=17 --build-arg NODE_VERSION=22 --build-arg COMPILE_SDK=35 -f Dockerfile .
- https://github.com/kriskda/docker-github-action-android-container (comes with emulator which we don't need)
- https://github.com/jordond/docker-android-github-runner (desire more configurability - re:version of myoung34 container, etc)