RSSAmplifier

Cody Brunner · Aug 10, 2026

Speeding Up Self-Hosted Forgejo CI

0
Sign in to vote or save

Cody Brunner · Cody Brunner

A mock technical book cover reading Speeding Up Self-Hosted CI, with an anvil forging a compressed CI pipeline in amber sparks.

· 7 min read

Technology #bun #ci-cd #devops #docker #forgejo #home-server #performance #playwright #self-hosting #shiki #sveltekit #umbrel

A while back I moved my personal projects off GitHub and onto a self-hosted Forgejo instance running on an Umbrel box in my apartment. I wrote about why I did that, and later about wiring its activity feed into this site. The part I have not written about is what you have to start thinking about when you are running CI/CD on your own hardware instead of someone else’s machines in the cloud.

My Umbrel device has an N100 chip with four cores and 16GB of RAM. Every pull request, as well as every subsequent push, kicks off a code-review pipeline, and a push from the staging branch to production triggers a deployment to Fly. It was not just this website where I started noticing how slow the workflows were getting, but it was by far the worst offender.

The thing that immediately stuck out was the build time. It crept up more and more with every article I released. In true developer fashion, I did not bother investing the time to fix it until it became enough of a hassle. Enough was more than six minutes of build time, and that is for every pull request, every push to that pull request, and again when building inside the Docker container on Fly.

Where the Time Actually Went

The first thing I did was stop guessing and read the run history. The checks themselves were not the problem. Lint was 16 seconds. Type check was 8. The actual work was cheap. The time was being burned everywhere around it, and once I laid it out, the pattern was obvious.

Every job rebuilt its world from scratch. Job containers are ephemeral, so setup-bun re-downloaded bun on every run, and the browser-based tests pulled down roughly 150MB of Playwright chromium every single time. Nothing was ever kept warm.

actions/cache was theater on a single box. The bun cache was tarballed, saved, and restored through the runner’s cache server on every run, which is pure overhead when the “remote” cache lives on the same disk the job is already sitting on. That is what the 17-second “Complete job” step at the end of the run actually was: saving a cache I did not need.

The Svelte config was evaluated three separate times per pipeline (once each for svelte-kit sync, svelte-check, and vite build), and every one of those evaluations eagerly built a Shiki highlighter with 44 language grammars, when my content only ever uses 20 of them.

And prerendering was serial. kit.prerender.concurrency defaults to 1, so every article, times two locales, rendered one at a time. That is the term that scaled with new content. Every post I wrote made that line a little longer. I was writing my own CI into a slow death.

So the pipeline was not slow because the work was hard. It was slow because it rebuilt everything on every run and then rendered the whole site single file.

Cheapest Levers First

I have a rule for this kind of thing: try the config one-liners before you touch any infrastructure. The dumb, boring changes are usually where the minutes hide, and they cost you nothing to undo.

The first domino was not even in CI. My Fly deploys had started timing out uploading a 119MB build context, most of which was uncompressed article hero images. So I recompressed the JPEGs and converted the oversized PNGs to lossy WebP capped at 2000px. src/ went from 119MB down to 22MB, and the deploy timeouts stopped. One afternoon, zero code.

Next, Shiki. I had Claude audit every code fence in src/content and trim the grammar list down to the 20 languages I actually use. Aliases like ts resolving to typescript come for free, and any language I do not load falls back to plaintext, so nothing can break; a fence just renders without color in the worst case.

svelte.config.js
const highlighter = await createHighlighter({
	themes: ['everforest-dark', 'everforest-light'],
	langs: [
		'bash',
		'css',
		'diff',
		'elixir',
		'go',
		'html',
		'javascript',
		'json',
		'jsx',
		'makefile',
		'plaintext',
		'python',
		'scss',
		'shellscript',
		'sql',
		'ssh-config',
		'toml',
		'tsx',
		'typescript',
		'yaml'
	]
});

Then prerender concurrency, which is a one-line change that matches the box’s core count:

svelte.config.js
prerender: {
	concurrency: 4,
}

Those two config changes are where the build time fell off a cliff. The build step went from 5m41s to 2m35s, and I had not touched a single line of infrastructure yet.

Baking a Runner Image

The download-the-world problem was the part that needed the runner itself to change. So I baked a custom image, ci-www, with bun and Playwright chromium already installed, and registered it as a new runner label alongside the stock node label (other repos still depend on node, so I did not want to disturb it). No registry required, because the image builds on the same Docker daemon that starts the job containers.

The Dockerfile is short, but two lines in it cost me real time to get right. Bun’s installer drops the binary in ~/.bun/bin and adds a line to your shell profile, which a non-login CI shell never reads, so the binary is installed and invisible at the same time. The fix is to point the install at a directory that is already on PATH:

ENV BUN_INSTALL=/usr/local
RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14"

Playwright gets the same treatment with a fixed browsers path (ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright) so it resolves no matter what $HOME the job happens to run under.

With the image handling the downloads, actions/cache had nothing left to justify it. I replaced it with a persistent named volume mounted into every job container, so bun’s download cache just stays warm on disk between runs. No tarball, no save, no restore.

runner-config.yml
container:
  options: '-v bun-install-cache:/root/.bun/install/cache'
  valid_volumes:
    - bun-install-cache

And then the satisfying part: deleting the now-dead steps from the workflow.

.forgejo/workflows/code-review.yml
-    runs-on: node
+    runs-on: ci-www
     steps:
       - uses: actions/checkout@v4
-      - uses: https://github.com/oven-sh/setup-bun@v2
-      - uses: actions/cache@v4
-        with:
-          path: ~/.bun/install/cache
-          key: bun-cache-${{ hashFiles('bun.lock') }}
-          restore-keys: bun-cache-
       - run: bun install --frozen-lockfile

The Gotchas

Every one of these cost me actual debugging time, so here they are as a favor to future me and to you.

runner.labels replaces, it does not merge. Hand it a non-empty list and it silently drops every label you did not repeat, and any workflow pinned to a dropped label queues forever with no error. Carry every label over, including the ones for other repos.

valid_volumes is a silent allowlist. A volume mount in container.options that is not also listed under valid_volumes just does not happen. No warning, no cache, no clue as to why.

The Payoff

Here is the same job, before and after, straight from the run history:

StepBeforeAfter
Set up job4s2s
Checkout9s1s
setup-bun2sgone
actions/cache restore1sgone
bun install3s3s
Lint16s14s
Paraglide compile1s2s
Type check8s7s
Build5m41s2m35s
Complete job (cache save)17s2s
Total~6m42s~3m06s

Roughly cut in half, and most of that came from the build step alone.

Wrap Up

I continue to learn more and more about self-hosting and maintaining hardware with this venture. I cannot say it is easy nor is it fun when you are just trying to ship, but it is worth the headaches to know I am in control of my code and data.

Until next time,

Cody

Read the original on codybrunner.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.