⚠️ Deprecated (August 2026): This site now deploys directly to Cloudflare Pages via npx wrangler. The GitLab Pages and self-hosted runner architecture described below has been replaced.

I pushed a commit at 2 AM from a Chromebook with 2.7GB of RAM. Forty-five seconds later, the live site at nurazhar.com had a new blog post. No manual deploy, no SSH, no rsync. The whole pipeline ran itself — and it cost me nothing against GitLab’s free tier.

This post is the complete architecture of that pipeline. Every stage, every tool, every decision. If you’ve ever wondered how a solo developer ships production infrastructure without a team, this is the answer.


The Big Picture

Four stages. One commit. Zero manual steps. Let’s break each one down.


Stage 1: Local — The Gatekeeper

The first stage runs on this machine — a Chromebook running Debian in a Linux container. Three gates must pass before I’m allowed to commit:

Gate What It Catches Time
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 or removed posts ~2s

bb build is the heavy lifter. It invokes Babashka (a native Clojure scripting engine) which orchestrates pandoc (Markdown to HTML) and the diagram renderer across every .md file in the corpus.

The key insight: these gates run locally before the commit. If any gate fails, the commit is blocked. The CI runner almost never sees a broken build.


Stage 2: CI Runner — The Build Machine

The .gitlab-ci.yml defines a single job that runs on any GitLab runner — shared or self-hosted, x86_64 or ARM64.

The Image

image: debian:stable-slim

Debian stable-slim — small, reliable, available on every architecture. No custom Docker image to maintain. The job installs everything it needs from scratch.

The Tools

Five packages are installed in before_script:

Package Why
curl Download install scripts and release archives
ca-certificates HTTPS trust for GitHub downloads
tar Extract the renderer and Babashka archives
pandoc Convert Markdown posts to HTML
default-jre-headless Babashka needs Java to resolve Maven deps

Then two pinned binaries are downloaded:

Tool Version Purpose
Babashka 1.12.196 Clojure scripting engine — the orchestrator
Renderer 0.7.1 Diagram renderer — SVGs from diagram source files

The Caching Strategy

Two independent caches prevent redundant work:

Cache Keyed On Contains
Site cache dev.clj + styles.css public/ directory
Tools cache BB_VERSION + RENDERER_VERSION .tools/$(uname -m)/ binaries

The tools cache is architecture-partitioned. The path .tools/$(uname -m)/ means x86_64 and ARM64 binaries never collide. This is the trick that makes the same pipeline work on both GitLab’s shared runners and my ARM Chromebook.

The Architecture Detection

A case statement maps uname -m to the GitHub release archive name. Babashka’s install script auto-detects architecture, but the renderer requires explicit mapping.

The Smoke Tests

After each tool install, a smoke test runs:

bb --version
pandoc --version | head -1
renderer --version

If any smoke test fails, the job fails immediately — no wasted build time on broken tools.


Stage 3: GitLab Pages — The CDN

The runner uploads public/ as a pipeline artifact. GitLab Pages takes over:

The custom domain is configured once in Settings → Pages. DNS points to GitLab’s CDN via a CNAME record. No server-side processing. No database. 180+ markdown files compiled to static HTML and served from edge caches.


Stage 4: Live — The Proof

The visitor hits https://nurazhar.com. The Pages CDN serves the static site. The sitemap carries ISO-8601 <lastmod> timestamps — Google Search Console stays happy.

The whole chain, from git push to live:

Metric Cold Build Warm Build
Local gates ~30s ~15s
CI pipeline ~2-3 min ~1.5-2 min
Pages deploy ~15s ~15s
Total to live ~3 min ~2 min

The Publish Workflow

For posts that need the full treatment, bb scripts/publish.bb <slug> runs an eight-step workflow:

Step 6 checks if the self-hosted runner is alive. If not, it starts it. Step 8 hits the live URL and checks for HTTP 200.


The Runner Problem and Its Solution

The self-hosted runner dies when the shell session ends. Every reboot, every laptop close — the runner stops picking up jobs.

The fix: setsid

setsid ~/.local/bin/gitlab-runner run \
  --config ~/.config/gitlab-runner/config.toml \
  > ~/.local/var/gitlab-runner.log 2>&1 < /dev/null &

setsid starts the process in a new session, detached from the terminal. But it doesn’t survive reboot — that’s why the pipeline also works on shared runners.

Self-Hosted vs Shared Runners

Dimension Self-Hosted Shared Runner
Quota cost $0 against quota Burns 400 min/mo cap
Reliability Dies when laptop sleeps Always on
Speed Native tools, warm cache Install tools each run
ARM support Native on Chromebook GitLab has ARM64 runners
Best for Frequent pushes Infrequent pushes, reliability

The current .gitlab-ci.yml has no tags — it runs on whichever runner picks it up first.


The Cost Analysis

Component Cost Notes
Babashka $0 Open source
pandoc $0 Open source
Renderer $0 Open source
GitLab Pages + runner $0 Free tier hosting, ~2 min/push within 400 min/mo cap
Domain + SSL $0 Cloudflare DNS, Let’s Encrypt via GitLab
Total $0/mo Everything is free

The only cost is time — ~30 seconds for local gates, ~2 minutes for the full pipeline.


The Security Layer

The pipeline has three security boundaries:

Boundary What It Protects
GitLab CI variables Secrets never touch the repo
DNS verification Custom domain ownership proof
.gitignore .env files never committed

These are small things, but they prevent the “oops I pushed my API key” class of bugs.


The Failure Modes

Symptom Likely Cause Fix
Pipeline never starts Runner is down ps aux \| grep gitlab-runner; restart with setsid
Build fails on runner but works locally Tool version drift Match local versions; check pinned versions
Live site shows old content CDN cache Hard refresh; Pages CDN caches aggressively
401 on custom domain DNS verification missing Add TXT record, click Verify in GitLab
Pre-commit hook times out bb build is slow --no-verify after manual gates

Why This Architecture Matters

This pipeline is dogfooding in production. Every blog post is a deploy. Every deploy tests the pipeline.

The key decisions:

  1. No custom Docker image — install tools in before_script so any runner works
  2. Architecture-partitioned caches — x86_64 and ARM64 never collide
  3. Pinned versions — reproducible builds across environments
  4. Local gates first — CI catches what local misses, not the other way around
  5. No tags — the pipeline runs on self-hosted OR shared runners

Each failure shaped the architecture.


The Takeaway

Your CI should depend on your repository, not on which laptop remembered to stay awake. Install tools in CI, pin versions, cache aggressively, and let any runner do the work.


This post was itself deployed through the pipeline it describes. The blog is the production environment, and every post is proof that the architecture works.


Skills Demonstrated

This blog is a production system that demonstrates real-world infrastructure competencies:

Pipeline Component Infrastructure Skill Why It Matters
Local gates (bb buildbb testbb validate-links) Shift-left testing, pre-commit validation Catches broken frontmatter, link rot, and regressions before CI sees them
GitLab CI/CD + runner topology CI/CD pipeline design, infra decisions Canonical deploy path; evolved from self-hosted shell executor to architecture-agnostic shared runners
Pinned tool versions (BB_VERSION, RENDERER_VERSION) Reproducible builds, dependency management Same binary on local, CI, x86_64, and ARM64 — zero drift
Architecture-partitioned caches (.tools/$(uname -m)/) Multi-platform build systems x86_64 and ARM64 binaries never collide; same pipeline runs everywhere
GitLab CI variables + Debian slim image Secrets management, container security API tokens never touch repo; Debian slim = minimal attack surface, fresh tools
Cloudflare DNS + TXT verification Domain ownership proof, DNS management Custom domain with HTTPS via GitLab Pages + Let’s Encrypt

What this means: A solo developer shipping 180+ posts to production with zero monthly cost, architecture-agnostic builds, and a pipeline that runs on any machine — including a Chromebook with 2.7GB of RAM. This is not a tutorial. This is what production looks like when you treat every deploy as proof.