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.
Identical paths
Section titled “Identical paths”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.
The filtered prune
Section titled “The filtered prune”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.
What it will not touch, and why
Section titled “What it will not touch, and why”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.
The leftover no prune can reach
Section titled “The leftover no prune can reach”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 || trueBoth 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.