The 4-Stage Chain That Ships This Site
I push a commit, and the live site updates in under a minute — about
30 seconds on a warm cache, closer to 90 seconds when the build pipeline
has changed. No manual deploys, no rsync, no FTP. The whole
thing is one chain with four stages, and the post you’re reading is the
first one I wrote while looking at it from the outside.
This is the workflow that publishes every post on this blog. Diagram first, then each stage in detail.
The Chain
Stage 1: Local
The first stage is just me, the corpus, and bb.
I write a post in markdown, save it. The bb build
pipeline (Babashka, Pandoc, and the diagram renderer) compiles every
.md file in the corpus to HTML, generates the index, and
emits the sitemap. Three gates run before I’m allowed to commit:
bb build— producespublic/bb test— runs the Clojure test suitebb validate-links— checks every internal link still resolves
If any gate fails, the commit is blocked. The pre-commit
hook does the same checks, but I’ve started using
--no-verify after running them manually — the hook takes
longer than the gates themselves on this machine.
| Gate | What it catches | Cost |
|---|---|---|
bb build |
Broken frontmatter, diagram compile errors, missing required fields | ~25s cold, ~10s warm |
bb test |
Clojure regressions in the build pipeline | ~3s |
bb validate-links |
Dead links from renamed/removed posts | ~2s |
Stage 2: Local Runner
git push origin main triggers the next stage. But it’s
not GitLab’s runner that picks up the job — it’s a self-hosted
gitlab-runner process running on this same machine, started
headless via setsid so it survives shell death.
The runner polls GitLab.com every few seconds, sees the new commit,
pulls the job, and runs the same
bb build && bb validate-links commands from
.gitlab-ci.yml. The tag homepage-self-hosted
on the pages job is what routes the work to my box instead
of GitLab’s shared runners — and shared-runner minutes are the
only thing that counts against GitLab’s 400-min/mo free quota,
so this is what makes the chain free.
The runner outputs public/ — the same artifact
bb build produced locally. That’s the point: the runner
isn’t doing anything I couldn’t do locally, it’s just doing it as the
single canonical deploy path so I never rsync to production
by hand.
Stage 3: GitLab Pages
The runner uploads public/ to GitLab Pages. GitLab
stores the artifact, the pipeline reports success, and the
Pages service publishes the artifact to its CDN.
I have a custom domain (nurazhar.com) aliased via DNS
CNAME to nurazhar.gitlab.io. The Pages service respects
that — every URL served from the CDN carries the
nurazhar.com origin. The .gitlab-ci.yml
doesn’t even mention the domain; that’s all configured once in
Settings → Pages.
Stage 4: Live
The visitor hits https://nurazhar.com. Pages CDN serves
the static HTML, CSS, JS, and assets from the artifact. No server-side
processing, no database, no PHP. The whole site is 167 markdown
files compiled to static HTML and served from edge caches.
The sitemap at /sitemap.xml carries the full ISO-8601
<lastmod> for every post — a small thing I added
recently so Google Search Console stops complaining about date-only
timestamps. The static site rebuilds on every push, so the sitemap is
always current.
What the Chain Prevents
| Risk | Without the chain | With the chain |
|---|---|---|
| Manual deploy drift | I rsync public/ to a server, then forget the server
exists |
There’s no server. The chain is the only path. |
| “Works on my machine” | A commit passes local gates but breaks on the production server | The runner runs the same gates on the same commit. If it works locally, it works live. |
| Quota anxiety | I check the shared-runner counter before every push | Counter stays at 0. Self-hosted doesn’t count. |
| Stale deploys | I push, walk away, come back to a broken site from a half-failed deploy | Pipeline reports success/failure per stage. No silent half-deploys. |
| Reverting complexity | Reverting means SSHing into the server, finding the old
public/, swapping it back |
git revert + push. The chain re-runs. |
Failure Modes
| Symptom | Likely cause | Fix |
|---|---|---|
| Pipeline never starts | Runner is down (laptop asleep, shell died) | ps aux \| grep gitlab-runner; restart with the
setsid command in AGENTS.md |
| Pipeline runs but Pages doesn’t update | Artifact upload failed | Check pipeline logs for the “Uploading artifacts” step |
| Live site shows old content | CDN cache or browser cache | Hard refresh; Pages CDN caches aggressively |
bb build fails on runner but not locally |
Tool version drift on the runner host | Match local versions; runner uses bb,
pandoc, and the diagram renderer natively |
| Pre-commit hook times out | bb build is slow on this machine |
--no-verify after manual gates (per AGENTS.md # Git
Workflow) |
The Takeaway
The 4-stage chain isn’t novel. It’s just
local → CI → Pages → live, the same pipeline every static
blog on GitLab has. What makes it work is the self-hosted
runner that removes the quota, the
--no-verify discipline that keeps the
gates fast, and the AGENTS.md rule that says the
chain is the only path — no manual rsync, no fallback
pipeline, no “I’ll just push and fix it later.”
Every post on this blog goes through the same four stages, in the same order, in the same short window — about 30 seconds on a warm cache, closer to 90 seconds cold. That’s the whole secret.
The cheapest, fastest, most boring deploy pipeline is the one you never think about.