The Complete CI/CD Architecture of a Solo Developer's Blog
⚠️ 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:
- No custom Docker image — install tools in
before_scriptso any runner works - Architecture-partitioned caches — x86_64 and ARM64 never collide
- Pinned versions — reproducible builds across environments
- Local gates first — CI catches what local misses, not the other way around
- 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 build → bb test →
bb 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.