Writing Workflows for Runaway Runners
Runaway runners aren’t identical to GitHub-hosted ones (ubuntu-latest). Most workflows run
unchanged, but a few patterns that work on a hosted runner behave differently here.
Routing jobs to a workload
Section titled “Routing jobs to a workload”Each workload registers its runners with one or more labels. Match
those labels in your workflow’s runs-on to route a job to that pool. self-hosted is always
implied, so include it alongside the workload’s label:
jobs: build: runs-on: [self-hosted, my-label] steps: - run: echo "running on Runaway"With one workload, runs-on: self-hosted is enough. With several, give each a distinct label and
match it so GitHub’s dispatcher sends each job to the right pool.
Skip GitHub’s Actions Cache for tool binaries
Section titled “Skip GitHub’s Actions Cache for tool binaries”Every workload has a persistent /cache volume mounted into every runner, so pnpm stores,
Playwright browsers, apt downloads, and ecosystem caches survive across runs locally. Actions like
actions/cache@v4 and actions/setup-node@v4 with cache: pnpm tar and zstd-compress the path and
round-trip it through GitHub’s Cache service every run — slower than reading the local volume.
Point your tools at /cache instead:
# No `cache: pnpm` — the store already lives at /cache/pnpm.- uses: actions/setup-node@v4 with: node-version: 22
# Browser binaries on the persistent volume; `playwright install`# no-ops on warm runs.env: PLAYWRIGHT_BROWSERS_PATH: /cache/ms-playwrightSee Caching for the full list of pre-wired ecosystem caches and how to point your own tools at the volume.
The runner is itself a container
Section titled “The runner is itself a container”Applies when the workload’s runtime is shared-daemon — runners
share the host’s Docker daemon. On dind and isolated-sysbox this doesn’t come up: a job’s
containers are children of the runner’s own daemon, not host siblings.
When a job runs docker compose up or docker run -p, the new containers spawn as siblings on
the host’s Docker daemon, not as children of the runner. The runner’s localhost:PORT is its own
loopback, so it can’t reach ports published on the host. Two ways to deal with it:
-
Connect the runner to the new stack’s network and address services by name:
Terminal window docker network connect <stack>_default $(hostname)# then talk to http://my-service:3000 -
Put both sides on a shared Docker network from the start by setting
networks:on both the runner side and the services in your compose file.
Bind mounts resolve on the host
Section titled “Bind mounts resolve on the host”The sibling rule applies to volumes too, and this one fails quietly. When a job runs
docker run -v "$PWD:/work" …, the path is resolved by the daemon that owns the socket — the
host’s — and not inside the runner. When the host has nothing at that path, Docker creates an
empty directory and mounts that.
Nothing reports a broken mount. The container starts, /work is empty, and the failure arrives as
whatever the tool inside does next: a missing package.json, a binary that isn’t found, a test run
that collects no files.
Runaway covers the case that matters. A shared-daemon runner gets its own workspace tree under
/var/lib/runaway/work/<runner-name>, bound from the host at that identical path, with
RUNNER_WORKDIR pinned to it. $GITHUB_WORKSPACE therefore names the same bytes on both sides and
mounting your checkout works. Three things follow:
- Don’t set
RUNNER_WORKDIRyourself. It’s a reserved key in custom env for this reason — changing it leaves the bind pointing at a path the runner no longer uses. - Only the workspace is shared. A directory the job creates elsewhere —
/tmp/fixtures, say — exists in the runner and not on the host, so mounting it hands the container an empty one. Keep anything a job needs to mount under$GITHUB_WORKSPACE. - The path is per-runner and not stable between runs. It carries the runner’s name, so don’t
hard-code it anywhere; read
$GITHUB_WORKSPACE. The tree is reaped when the runner exits.
Hygiene for jobs that spawn containers
Section titled “Hygiene for jobs that spawn containers”Containers your job creates show up on the host’s Docker daemon, not nested inside the runner. Keep them tidy so concurrent runs on the same host don’t collide:
-
Use unique names per run. Don’t use a fixed
container_name:. Pass--project-name "$GITHUB_RUN_ID"(or a UUID) and let compose’s default<project>-<service>-<index>naming keep two jobs on one host from clashing. -
Stamp a sweep label and clean up at the end. Add
--label run-id="$GITHUB_RUN_ID"(or aslabels:in compose), then run a final step:Terminal window docker ps -aq --filter "label=run-id=$GITHUB_RUN_ID" | xargs -r docker rm -fvThis catches what
docker compose downmissed.-vmatters: an image with aVOLUMEdirective — which is most database images — creates an anonymous volume per container, and plaindocker rm -fremoves the container and leaves the volume behind. One per run, on the host, forever. It only ever removes anonymous volumes, so a named cache or data volume is untouched.