An external provider for GARM that runs ephemeral GitHub Actions runners as Docker containers on a single Docker host. GARM owns GitHub App authentication, scale-set long-polling, JIT runner-config generation, and pool scheduling; this provider is responsible only for instance lifecycle on that one host, invoked as a subprocess (environment variables and a stdin JSON payload in, a stdout JSON payload and exit code out).
Target deployment: a single Docker host, NAS-first (Synology DSM, Unraid,
Raspberry Pi, and generic Linux), on both linux/amd64 and linux/arm64.
Full design rationale lives in docs/plan.md and the
Architecture Decision Records.
-
Per-job isolation topology. Every allocation gets its own bridge network, runner container, and workspace volume — plus, in DinD modes, its own DinD sidecar, socket volume, and
dind-statevolume — created together and torn down together. See ADR-001 and ADR-004. -
Three
dind_modeoptions, capped by an operatorallowed_dind_modesceiling that a pool'sextra_specscan never escalate past. The ceiling itself defaults to["none"]— fail-closed: DinD is unavailable until an operator both widens the ceiling and sets a digest-pinneddind_image, so no pool can obtain a privileged sidecar by omission.none(default) — no DinD sidecar at all; adockercall inside the job fails fast.privileged-sidecar— the only DinD mechanism that works on every NAS target (no Sysbox dependency), but the sidecar runsPrivileged=true: a malicious job's own code can pivot from that privileged daemon to a host compromise. This is a real, direct residual risk, stated honestly rather than downplayed — see ADR-001's "Residual risk" section. The sidecar shares the runner's network namespace, as in an ARC pod, so a port a job publishes through it (docker run -p, testcontainers,docker compose) is reachable on the runner's127.0.0.1/localhost, as on a GitHub-hosted runner; the runner and the inner containers share one port space.sysbox-runc— reduced-privilege alternative (noPrivileged=true) for hosts that have Sysbox installed; not realistically installable on Synology DSM or Unraid today. Its sidecar keeps its own network namespace, so published ports are reached at the sidecar's container name (<instance>-dind), not on the runner'slocalhost.
-
Persistent, repo-scoped caches. Toolcache and pnpm-store volumes survive across jobs against the same repository, keyed by a normalized repo URL plus generation/pnpm-major salts. Cache volumes are never deleted automatically: the opportunistic GC pass is log-only — it detects stale and superseded caches and logs them for operator visibility, and reclaiming them is a deliberate operator command. This is by design (ADR-003): Docker has no atomic label-qualified volume delete, so an inspect-then-delete-by-name is inherently racy, and the provider will not delete a volume it cannot prove is its own. Reclaim them at a quiescent moment with the controller-scoped purge:
docker volume prune -a \ --filter label=garm.docker/cache=true \ --filter label=garm.docker/controller-id=<your-controller-id>
(
docker volume pruneskips in-use volumes, so a warm cache held by a live job is never reclaimed.) The one destructive cache action the provider does perform is pruning files inside the diagnostic-log volume todiagnostic_log_retention_days, gated on full-identity validation. Org/enterprise-shared caches are an explicit opt-in (allow_org_shared), off by default. NAS operators with limited storage should plan for this manual step. See ADR-003. -
IPv4-only
localhostin the runner. The runner container is created with IPv6 disabled in its network namespace, so::1is never usable andlocalhostbehaves as127.0.0.1, as on a VM host or a GitHub-hosted runner. From Docker Engine 26 a container on a network without IPv6 would otherwise keep::1 localhost, and a server bound tolocalhost(::1) is then refused by clients such as Node that skip::1(ECONNREFUSED 127.0.0.1); with the sysctl Docker 26+ also drops the line. Engine 25 and earlier already disable IPv6 there and keep a harmless, dead::1 localhostline. If the daemon cannot apply the sysctl, the provider logs a warning and recreates the runner once without it: on a host whose kernel has no IPv6localhostis IPv4-only anyway, while any other refusal (permission, read-only/proc/sys, policy) is a degraded case where IPv6 may stay enabled, which the warning and the live checks call out. Jobs that bind or dial::1explicitly do not work. See ADR-002. -
Fail-closed
extra_specs. The GARM-admin trust tier (poolextra_specs) is bounded by a published,additionalProperties: falseJSON Schema plus config-checked ceilings:dind_modebyallowed_dind_modes,runner_memory/dind_memoryby the configured limit (rejected, never clamped, if it exceeds the ceiling), andextra_envby an operator allowlist (default empty) plus a hard-reserved name set rejected even if an operator allowlists it by mistake. Noextra_specsfield, ever, can set a raw image reference,docker_host, or theprivilegedflag. The one exception to "unknown keys are rejected" is GARM's ownrunner_install_template, which GARM v0.2.x injects into every create and which is accepted and ignored. Seedocs/config-reference.mdand ADR-005. -
v0.1.0 + v0.1.1 interface. The one binary satisfies both GARM
ExternalProviderinterface versions:GARM_INTERFACE_VERSION=v0.1.1turns on self-description (GetSupportedInterfaceVersions,GetConfigJSONSchema,GetExtraSpecsJSONSchema,ValidatePoolInfo); omitting it (or settingv0.1.0) gets the original eight lifecycle methods only. -
Pools and scale sets. Both work with GARM v0.2.x under either interface version. Under
v0.1.1, GARM sends scale-set delete/get/start/stop calls without a pool ID; the provider accepts that (see research.md §1.D). On GARM v0.2.1 and earlier, register the provider withinterface_version = "v0.1.0"(see Register with GARM for why). -
Runner status reported by the provider. After delivering credentials, the provider reports the runner
installingto GARM's instance callback, thenidleonce the runner logsListening for Jobs(bounded wait, default 90 s). GARM v0.2.1 scale sets need this: they only move a runner toactivefromidle, so a runner left atpendingstalls the scale set when it picks up a job (relaxed upstream in cloudbase/garm#817, not in a release as of 2026-10-11). Reporting narrows the stall to a brief, self-clearing retry window if a job is assigned beforeidleis posted; it returns wheneveridleis not posted (timeout, reporting disabled, a failed callback or watcher, a GARM restart mid-wait). It is on by default; the call is made by the provider process, so the runner container still never sees the instance token. See[status_report].
For each pushed vX.Y.Z tag,
.github/workflows/release.yml and
.github/workflows/images.yml publish, from
the same tag:
- Release binaries — static
linux/amd64andlinux/arm64binaries (CGO_ENABLED=0, no libc dependency) with aSHA256SUMSchecksum file, attached to a draft GitHub Release. - Container images —
ghcr.io/tzuh-hsu/garm-provider-docker(a distribution vehicle for the binary — see below) andghcr.io/tzuh-hsu/garm-runner-nobleandghcr.io/tzuh-hsu/garm-runner-resolute(the Ubuntu 24.04 and 26.04 runner images), all multi-arch (linux/amd64+linux/arm64) with SBOM and provenance attestations, tagged:<tag>. The tag push does not move:latest: that happens only when the tag's GitHub Release becomes a full release — its draft published as a non-pre-release, or a published pre-release later changed to a full release.
v0.2.4 is the latest published release: its release binaries and
SHA256SUMS checksum file are downloadable from the
v0.2.4 release page,
and its container images are on GHCR as both :v0.2.4 and :latest. It
passed the end-to-end gate against GARM v0.2.1 on the Ubuntu 26.04 runner
image, with dind_mode=none and with privileged-sidecar (including a check
that a port published by the sidecar's daemon is reachable on the runner's
127.0.0.1); the Ubuntu 24.04 image was gated with v0.2.3 and not re-run for
v0.2.4 (see docs/demo-m0.md). v0.2.0 does not work
with GARM v0.2.x.
(v0.1.0's tag and :v0.1.0 images still exist, but its GitHub Release
draft has since been removed.) You can also build from source instead
(see go.mod's go directive for the required Go version):
git clone https://github.com/TzuH-Hsu/garm-provider-docker
cd garm-provider-docker
go build -o garm-provider-docker .The provider cannot be run as a container. GARM does not start a
container for an external provider — it execs the configured
provider_executable filesystem path directly, passing the request
through environment variables and stdin (garm-provider-common v0.1.9:
exec.CommandContext(ctx, providerBin)). An OCI image reference is not a
path to an executable, so it cannot be a provider_executable, and there
is no hook that would make GARM run docker run … on your behalf. Even
setting that aside, the image's distroless nonroot process (uid 65532)
could not open a root:docker host socket.
What the image is good for is getting the binary onto a host, or into your own GARM image, without a Go toolchain:
# Extract the static binary to the path GARM will exec.
cid="$(docker create ghcr.io/tzuh-hsu/garm-provider-docker:<tag>)"
docker cp "${cid}:/garm-provider-docker" /opt/garm/providers.d/garm-provider-docker
docker rm "${cid}"
chmod +x /opt/garm/providers.d/garm-provider-dockerIf you build your own GARM image, copy the binary in at build time instead:
COPY --from=ghcr.io/tzuh-hsu/garm-provider-docker:<tag> \
/garm-provider-docker /opt/garm/providers.d/garm-provider-dockerEither way, the binary GARM execs runs in GARM's own process context and needs access to the host Docker socket from there — for a containerized GARM, that means the socket is mounted into the GARM container, which is a property of your GARM deployment, not of this provider. Never mount it into a runner or DinD-sidecar container, which must stay isolated from the host daemon in every mode (see Security model below).
A small docker run exec-wrapper that would let the image act as a
provider_executable is a plausible future addition, but none is shipped
today, and none should be used until it has been tested end to end against
a real GARM — the credential and stdin/stdout contract is exactly the part
a naive wrapper gets wrong.
Add a [[provider]] section to GARM's own config.toml (commonly
/etc/garm/config.toml):
[[provider]]
name = "docker_local"
provider_type = "external"
description = "Single-host Docker provider (garm-provider-docker)"
[provider.external]
provider_executable = "/opt/garm/providers.d/garm-provider-docker"
config_file = "/etc/garm/garm-provider-docker.toml"
interface_version = "v0.1.0" # recommended on GARM <= v0.2.1; see belowWhich interface_version? On GARM v0.2.1 and earlier, use
v0.1.0. GARM v0.2.1's own v0.1.1 wrapper inverts the error check in
ListInstances (if err == nil in runner/providers/v0.1.1/external.go),
so every successful list is reported as
provider binary ... returned error: %!s(<nil>) and GARM's runner
reconciliation for pools and scale sets keeps failing. This is an upstream
GARM bug, fixed by cloudbase/garm#878
(commit 488cf98b, merged 2026-09-11) but not in any release as of
2026-10-11. Nothing is lost by choosing v0.1.0: GARM v0.2.1 never calls
the v0.1.1-only self-description commands (GetSupportedInterfaceVersions,
GetConfigJSONSchema, GetExtraSpecsJSONSchema, ValidatePoolInfo) —
you can still run them by hand with GARM_INTERFACE_VERSION=v0.1.1 (see
docs/config-reference.md). Switch to
v0.1.1 once a GARM release includes #878; this provider supports both.
Running GARM in a container? Start it with an init process
(docker run --init, or init: true in Docker Compose). The provider reports
runner status from a short-lived background process that must be reaped by
PID 1 when it exits; GARM's own image runs /bin/garm as PID 1, which does
not reap it. Without an init the provider detects this and waits for the
runner inside CreateInstance instead, which makes creates slower (see
[status_report]).
Restart/reload GARM, confirm with garm-cli provider list, then create a
pool against it:
garm-cli pool add --repo <REPO> --provider-name docker_local \
--min-idle-runners 0 --max-runners <N> --image <informational> \
--flavor <informational> --tags <your-labels>--image/--flavor are required by garm-cli but are informational
only for this provider: image and resource selection are controlled by
the provider's own config and, per pool, extra_specs.flavor — never by
the pool's image/flavor fields (see docs/config-reference.md).
The smallest working config (none mode, everything else defaulted) —
see examples/config.minimal.toml:
runner_image = "ghcr.io/tzuh-hsu/garm-runner-noble@sha256:<your-resolved-digest>"Every other key falls back to its documented default: dind_mode = "none",
docker_host = "unix:///var/run/docker.sock", storage_driver = "overlay2", persistent caches enabled at their ADR-003 defaults, and
[network].internal = false.
See examples/config.full.toml for every
configurable key, commented. Both example files are loaded through the real
config loader by internal/config/examples_test.go on every change, so
they can never silently drift out of sync with the code.
For the full reference — every key, the extra_specs allowlist, the
reserved-env set, and the operator ceilings — see
docs/config-reference.md. Both contracts are
also published as machine-readable JSON Schemas the binary emits itself
(v0.1.1):
GARM_INTERFACE_VERSION=v0.1.1 GARM_COMMAND=GetConfigJSONSchema \
GARM_CONTROLLER_ID=x GARM_PROVIDER_CONFIG_FILE=/path/config.toml \
./garm-provider-docker
GARM_INTERFACE_VERSION=v0.1.1 GARM_COMMAND=GetExtraSpecsJSONSchema \
GARM_CONTROLLER_ID=x GARM_PROVIDER_CONFIG_FILE=/path/config.toml \
./garm-provider-dockerThe schemas themselves live at
internal/config/schema.json (provider
config) and
internal/extraspecs/schema.json
(extra_specs).
Two runner images are published with every release. Both are a
myoung34/github-runner base plus the same custom, JIT-aware entrypoint and
the same added packages (headless-Chrome libraries, fonts, pipx,
clang-format/clang-tidy, cmake, ninja); the runner user can install
tools into /usr/local/bin and /usr/local/lib.
| Image | Ubuntu | Toolchain | Use for |
|---|---|---|---|
ghcr.io/tzuh-hsu/garm-runner-noble |
24.04 | GCC 13, LLVM 18, Python 3.12 | workflows on ubuntu-24.04 |
ghcr.io/tzuh-hsu/garm-runner-resolute |
26.04 | GCC 15, LLVM 22, Python 3.14 | workflows on ubuntu-latest from 2026-10-19, or ubuntu-26.04 |
runner_image sets the default for every pool; a pool that needs the other
release selects a flavor that overrides it:
runner_image = "ghcr.io/tzuh-hsu/garm-runner-noble@sha256:<digest>"
[flavors.resolute]
runner_image = "ghcr.io/tzuh-hsu/garm-runner-resolute@sha256:<digest>"and the pool's extra_specs is {"flavor": "resolute"}. Pools on different
Ubuntu releases never share a toolcache or pnpm store volume: those caches are
keyed on the image's garm.docker/os-release label as well as the repository
(ADR-003). See runner-images/README.md for the
full entrypoint contract and package list.
Always pin runner_image (and dind_image, and any [flavors.*].runner_image
override) by digest — name@sha256:<64-hex> — never a mutable tag.
allow_unpinned_runner_image/allow_unpinned_dind_image are dev-only escape
hatches; never set them in a real deployment. Re-resolve the current digest
with:
docker buildx imagetools inspect ghcr.io/tzuh-hsu/garm-runner-noble:latest
docker buildx imagetools inspect ghcr.io/tzuh-hsu/garm-runner-resolute:latest
# or, without a local Docker daemon:
skopeo inspect docker://ghcr.io/tzuh-hsu/garm-runner-noble:latest- The host's Docker socket is never mounted into a runner or DinD-sidecar
container, in any
dind_mode. This is structural — no code path does it — not merely a convention enforced by review. - JIT credentials never reach the container as an environment variable or
a
docker inspect-visible mount. The instance token,.runner,.credentials, and.credentials_rsaparamsreach the runner only through a memory-backedtmpfs(/run/garm) the provider streams in viadocker execafter the container is created — never on host disk, never on the container's writable layer. extra_specsis fail-closed end to end. Schema-validated (additionalProperties: false), bounded by operator ceilings fordind_mode/memory, andextra_envis an opt-in allowlist (default empty) plus a hard-reserved name set (RUNNER_*,DOCKER_*,GARM_*,RUN_AS_ROOT,PATH,LD_*, …) rejected even if an operator allowlists it by mistake.- Cleanup is label-scoped, always. Every job-scoped resource — the
per-job network, the runner container, the workspace volume, and in DinD
modes the sidecar plus its socket and
dind-statevolumes — carriesgarm.docker/managed=trueplus controller-id/pool-id/instance-name labels. Shared resources are deliberately not instance-scoped: cache volumes carrygarm.docker/cache=trueplus controller-id (and their cache-kind/repo identity) but no pool-id or instance-name, precisely so they survive teardown and are reusable across jobs, and cache-helper containers carryrole=cache-helperwith no instance-name so the teardown/sweep predicate structurally excludes them andListInstancesnever reports them (ADR-003/ADR-004). Every cleanup path, including the manualRemoveAllInstancesrescue operation, filters on the labels appropriate to what it is collecting and never runs an unscopeddocker system pruneor equivalent. privileged-sidecaris honest about its limits. It protects other jobs' data and contains accidents, but a malicious job's code inside a privileged DinD daemon can reach the underlying host (see ADR-001's "Residual risk" section). Prefersysbox-runcornonefor genuinely untrusted job code; restrictprivileged-sidecarto trusted repositories if you use it at all.- Operators must ensure the host Docker daemon itself never listens on
TCP (
dockerd -H tcp://…or an equivalentdaemon.jsonhostsentry). A TCP-exposed host socket bypasses every per-job containment guarantee in this project regardless of any other setting.
- Synology DSM: the host filesystem is commonly btrfs (sometimes
overlay-on-overlay), which makes DinD's in-container storage-driver
autodetection unreliable. Always set
storage_driverexplicitly (overlay2is the default;vfsis the slower, more compatible fallback) rather than relying on autodetection. Sysbox has no supported install path on DSM, sosysbox-runcis not available there —privileged-sidecaris the only working DinD mechanism if you need DinD at all. - Unraid: the same Sysbox-availability gap as DSM —
sysbox-runcis not realistically installable;privileged-sidecarornoneare the practical choices. - Raspberry Pi / generic
linux/arm64hosts: fully supported —linux/arm64binaries and images are built by the same release workflow aslinux/amd64, not a separate or lagging build. Size the Docker daemon'sdefault-address-poolsfor your expected concurrent-job count: each allocation, in every mode, consumes one per-job network from that pool, and pool exhaustion is a real, host-level capacity ceiling independent of runner/DinD resource limits. - Whichever host you use, never configure the Docker daemon to listen on TCP — see Security model above.
M0 through M4 are implemented and tested on this branch. v0.2.4 is the
latest published release, with release binaries and container images
(:v0.2.4 and :latest) on GHCR; v0.1.0's tag and :v0.1.0 images
still exist, though its GitHub Release draft has since been removed. No
upstream pull request has been published yet. See
docs/plan.md for the milestone breakdown and
docs/adr/ for the design decisions behind each one.
Apache-2.0 — see LICENSE.