Skip to content

How it works

Three mechanisms decide whether this stack works. Two are built in; the third is yours to handle in the workflow, and it is the one that bites hardest.

The runner uses the host’s Docker daemon, through the mounted socket. So every path the runner hands to Docker is resolved on the host filesystem, not inside the runner container.

That matters as soon as a workflow step bind-mounts its own checkout — docker run -v "$PWD:/src", docker compose with a relative volume, a local dev-environment tool, testcontainers. The step passes a path from inside the container to a daemon outside it.

If the work directory is not mounted at its own host path, Docker does not error. It creates an empty directory at that path on the host and mounts that. The symptoms are:

  • a container that starts and serves nothing,
  • a build that cannot find files that are plainly in the repository,
  • a test suite that reports everything missing.

None of them names the cause. That is why RUNNER_WORK_ROOT appears three times in the compose file — as the environment variable, and as both sides of the volume. Change one, change all three.

How to check: while a job is running, ls the work root on the host. If your checkout is not there, this is your problem.

A persistent runner accumulates:

  • one image per branch build, pulled by every job,
  • every container a job forgets to tear down, left stopped.

Nothing on a Docker host removes either. A host that fills up this way does not report a full disk — it reports an unhealthy container, or a database that cannot write, or an image that cannot be pulled. The disk is never named.

The prune service removes both, hourly, for anything older than PRUNE_KEEP (default 24 hours).

The filter is as important as the prune. An unfiltered docker image prune -af deletes the images the next job needs and adds their download to every single run. Keeping a day’s worth is what makes the runner faster than a hosted one rather than slower.

Volumes. docker volume prune --all on a shared host destroys other stacks’ data: a volume whose container has been removed counts as unused. So it is opt-in through PRUNE_VOLUMES, and only sensible on a host that runs nothing but runners.

The work directory. actions/checkout defaults to clean: true, which runs git clean -ffdx — so each job already starts from a clean tree, ignored files included. What grows is _work/_tool and _work/_actions, and those are caches worth keeping: deleting them costs every job a re-download and buys back a few hundred megabytes.

State inside a volume your job reuses. A seeded test database is the classic case.

On a hosted runner the whole machine is discarded after the job, so most workflows never learned to clean up — there was nothing to clean. Move that same workflow to a persistent runner and the database from the last run is still there. The next run then tests against the previous run’s data instead of a fresh seed.

That is worse than a disk problem. It produces tests that pass for the wrong reason, or fail in ways that move between runs and read as flakiness.

No prune sees it, because the volume is in use by a container that is running perfectly well. It has to be torn down in the workflow:

- name: Tear down anything a previous run left behind
run: docker compose -f your-stack.yml down -v --remove-orphans || true
# … the job …
- name: Tear down
if: always()
run: docker compose -f your-stack.yml down -v --remove-orphans || true

Both halves. if: always() runs after a failure but not after a cancellation — and a cancelled run is routine on a job long enough to be worth self-hosting. The pre-flight is what actually guarantees a clean start.

Put the pre-flight where both the checkout and any tooling it needs already exist, but before anything is started. That is usually right after the tool installation steps.