Why our blog CI took 20 minutes (and how removing the cache fixed it)
The symptom
After merging a blog post PR, staging wouldn’t reflect the new content for 20+ minutes. Flux image automation updates staging within about 2 minutes of a new image appearing in GHCR — so the bottleneck was CI, not deployment.
Running gh run list --workflow "Blog CI/CD" showed docker jobs consistently taking 12–23 minutes on main pushes, while PR builds finished in 1–2 minutes.
Why PR builds are fast
The workflow has if: github.ref == 'refs/heads/main' on the docker job, so PR runs only execute lint, typecheck, and tests. The 1–2 minute difference was expected, not a bug.
Dissecting the docker job
Breaking down a real run (PR #1051, run 27283634090) by timestamp:
| Step | Time | Notes |
|---|---|---|
| GHA cache import | 70s | downloading mode=max cache |
| 63 COPY layers | ~3s | workspace package.json files |
RUN bun install |
14s | 988 packages |
RUN bun run build |
3s | markdown + bundle + Tailwind |
GHCR image push (#77) |
27s | includes TLS timeout retry |
GHA cache export (#78) |
468s (7m 48s) | ← the actual bottleneck |
The image push finished in 27 seconds. The remaining ~8 minutes was the GHA cache exporting.
Why mode=max is destructive here
cache-to: type=gha,mode=max saves every intermediate build layer — including the full oven/bun:1 base image — to the GHA cache server. That’s hundreds of MB uploaded on every single run.
The maximum possible saving from a cache hit is bun install: 14 seconds.
The overhead is:
- cache import: 70 seconds (wasted entirely on a cache miss)
- cache export: 468 seconds
Even on a perfect cache hit, the math is:
save 14s, spend 70s + 468s = net loss of 524 seconds per run
And cache misses were frequent. The first COPY layer is COPY package.json bun.lock ./, which invalidates whenever any workspace adds a dependency and regenerates bun.lock. In an active monorepo that’s nearly every merge.
The fix
Remove cache-from and cache-to from blog.yml:
- cache-from: type=gha,scope=${{ github.workflow }}
- cache-to: type=gha,mode=max,scope=${{ github.workflow }}
bun install now always runs (14s), but the 538 seconds of cache overhead disappears. Docker job: 10+ minutes → under 2 minutes.
The lesson
GHA cache with mode=max stores the entire build graph. That’s valuable when slow build steps are reliably cached — but when the step you’re caching takes 14 seconds, the cache overhead will almost always exceed the savings.
Before wiring up a build cache, measure both sides:
- What does a cache hit save? (instrument the slow step)
- How long does cache import + export take? (read the job logs)
If the overhead exceeds the savings, the cache is making CI slower, not faster.
