A previous post built
flare-runner:
a Cloudflare Worker that turns a workflow_job webhook into a one-shot
Cloudflare Container running a single CI job, then throws the container away.
One container per job, scale to zero, no standing runner box.
The catch with one-shot runners is the same thing that makes them nice: every job starts on a clean machine. A persistent runner accumulates a warm module cache, a warm compiler cache, and a local Docker layer cache between jobs. Throw the machine away after each job and all of that is gone. Naively, every build is a cold build.
The fix is to move the cache off the machine - into the GitHub Actions cache (plain HTTPS) for files, and into your container registry for image layers. Neither needs anything special on the runner. Here are two example workflows that show the whole pattern, plus the one mistake that quietly defeats it.
Example 1: build and test, with cache, no Docker
A compiled-language job spends most of its time in two places: downloading
dependencies and compiling. Both are cacheable, and actions/cache works
fine on a self-hosted ephemeral runner because it talks to the cache service
over HTTPS - it does not care that the disk is fresh.
For Go, the two directories that matter are the module cache (~/go/pkg/mod)
and the build cache (~/.cache/go-build). The second one is the interesting
part: it is the compiler’s incremental cache, and restoring it is what turns a
cold compile into a near-instant one.
jobs:
test:
runs-on: [self-hosted, cloudflare]
steps:
- uses: actions/checkout@v4
- name: Restore Go build + module cache
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: go-${{ runner.os }}-${{ hashFiles('go.sum') }}-${{ github.sha }}
restore-keys: |
go-${{ runner.os }}-${{ hashFiles('go.sum') }}-
go-${{ runner.os }}-
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false # we cache explicitly - see the gotcha below
- run: go test ./... -count=1
On a real backend (about 100 packages) this took the compile from 136
seconds cold to 8 seconds warm - roughly a 17x difference, and almost all of
the job’s wall-clock. -count=1 keeps the test results from being cached
(the tests still actually run) while the build cache still does its job.
The gotcha that silently disables the cache
actions/setup-go has a built-in cache (cache: true, the default). It is
keyed on your lockfile hash. That sounds right, but it has a property that
quietly breaks the warm path: actions/cache (which it uses underneath) will
not overwrite an entry that already exists for a key. Once an entry exists
for your current go.sum, setup-go restores that same frozen entry on every
later run and never saves the build cache your job just produced.
The symptom is maddening: the logs say “Cache restored successfully” on every run, yet the compile is slow every time. It restored a cache - just not one with your build objects in it.
The fix is the key in the example above: include ${{ github.sha }} so each
commit writes a fresh entry, and use restore-keys to fall back to the most
recent prefix match. Now every run restores the previous build cache and saves
an updated one. Disable setup-go’s own cache (cache: false) so the two do not
fight over the same directories.
The same shape works for other ecosystems - cache ~/.npm or the pnpm store
for Node, ~/.cache/pip (or the wheel cache) for Python. The principle is
identical: name the directories, key them so they refresh, let the cache
service ship them over HTTPS.
Example 2: build an image, with cache, still no Docker
Cloudflare Containers cannot nest Docker, so image builds use buildah, which builds and pushes an OCI image with no daemon. Two settings turn a naive buildah build into a cached one.
The first is the storage driver. buildah’s default vfs driver makes a full
copy of the filesystem for every layer - no copy-on-write. On a constrained
runner a multi-stage build will simply run the disk out (“no space left on
device”). Switch to overlay, which is copy-on-write, and the same build fits
comfortably and runs faster.
The second is the layer cache. A fresh container has no local layer cache, so
point buildah at a cache stored in the registry itself with
--layers --cache-from / --cache-to. It pushes intermediate layers to a
cache repository and pulls them back on the next run. (This needs buildah
1.24+; the older 1.23 that ships on Ubuntu 22.04 does not have --cache-to.
Ubuntu 24.04 ships 1.33.)
jobs:
image:
runs-on: [self-hosted, cloudflare]
steps:
- uses: actions/checkout@v4
- run: echo "$REGISTRY_PASSWORD" | buildah login -u "$REGISTRY_USER" --password-stdin "$REGISTRY"
- name: Build + push with a registry layer cache
run: |
buildah build \
--layers \
--cache-from "$REGISTRY/myapp-buildcache" \
--cache-to "$REGISTRY/myapp-buildcache" \
--isolation chroot --storage-driver overlay \
-t "$REGISTRY/myapp:$GITHUB_SHA" .
buildah push --storage-driver overlay "$REGISTRY/myapp:$GITHUB_SHA"
For a small image (a static binary copied onto a base, a config file or two)
this cached the base and setup layers and took the build from 54 seconds
cold to 35 warm. The win scales with how much of your Dockerfile is stable
setup - apt-get, pip install, a base image with system libraries - because
those are exactly the layers that hit the cache.
When the layer cache is a trap: compiling inside the image
There is one case where the registry layer cache looks like it should help and
mostly does not: a multi-stage Dockerfile that compiles your code inside the
build. The deps layers cache fine, but the RUN go build (or cargo build,
or npm run build) layer is invalidated the moment your source changes -
which is every commit. So that layer, the expensive one, runs cold every time.
A heavy backend image built this way took about 400 seconds even with the
cache warm, because the compile re-ran inside the layer on each commit.
The fix is to stop compiling inside the image. You already have a fast, cached compiler from Example 1 - so compile on the runner, then package the result into a thin runtime image:
# 1. compile natively, using the restored Go build cache (~8s warm)
- run: CGO_ENABLED=0 go build -o /tmp/myapp ./cmd/myapp
# 2. package the binary into a tiny runtime image - no compiler, no cache miss
- run: |
d=$(mktemp -d); cp /tmp/myapp "$d/"
cat > "$d/Dockerfile" <<'EOF'
FROM alpine:3.23
RUN apk add --no-cache ca-certificates
COPY myapp /usr/local/bin/myapp
ENTRYPOINT ["/usr/local/bin/myapp"]
EOF
buildah build --isolation chroot --storage-driver vfs -t "$REGISTRY/myapp:$GITHUB_SHA" "$d"
buildah push --storage-driver vfs "$REGISTRY/myapp:$GITHUB_SHA"
Now the compile is the cached 8-second step, and the image build is a trivial
copy. The same multi-stage build that ran ~400 seconds becomes a compile plus
a package in well under a minute - and the runtime image is smaller, because it
never contained a toolchain. (If your runtime is genuinely heavy - an image
built on a large base with its own system packages - keep building its real
Dockerfile with --storage-driver overlay and the registry cache; the thin
trick is specifically for “static binary on a small base”.)
The pattern
Ephemeral runners do not remove the need for a cache; they move it off the machine. Files go to the Actions cache over HTTPS, image layers go to your registry. Once both caches live somewhere the next clean container can reach, a one-job-then-gone runner is as warm as a standing box - without leaving a box running, holding your secrets, between jobs.
Both examples, and the runner that runs them, are at github.com/andrius/flare-runner.