Wintersalmon | Blog

Why our blog CI took 20 minutes (and how removing the cache fixed it)

3 min read

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.


Hungjoon

I'm Hungjoon, a software engineer based in South Korea. This is my long-form notebook — homelab, Kubernetes, AI infra, and whatever else keeps me up at night.