Skip to content

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.

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-playwright

See Caching for the full list of pre-wired ecosystem caches and how to point your own tools at the volume.

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.

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_WORKDIR yourself. 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.

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 as labels: in compose), then run a final step:

    Terminal window
    docker ps -aq --filter "label=run-id=$GITHUB_RUN_ID" | xargs -r docker rm -fv

    This catches what docker compose down missed. -v matters: an image with a VOLUME directive — which is most database images — creates an anonymous volume per container, and plain docker rm -f removes 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.