I spent three hours migrating this blog’s CI/CD pipeline and accidentally broke production for a day. The migration itself took 30 minutes. The other 2.5 hours were debugging why my posts weren’t reaching nurazhar.com. This is the DevSecOps teaching case I wish I’d read before starting — every diagram, every decision, and the security model behind both architectures.


The Before Architecture: Why It Existed

The mental model: GitLab was the center of gravity. It held the source, ran the CI, hosted the Pages artifact, and pushed a mirror to GitHub. The self-hosted runner on my laptop was the hack that made this free (GitLab only counts shared-runner minutes against the 400/mo quota). Every push triggered a webhook → local runner polls → builds → uploads to Pages.

Why this existed in the first place: When I built this blog, GitLab Pages was the simplest free static hosting with custom domains and SSL. The self-hosted runner let me bypass GitLab’s 400-minute monthly quota. The GitHub mirror was for discoverability. It was pragmatic, not elegant.


The Breaking Points: Three Failure Modes

Failure 1 — The Runner Dies: The gitlab-runner process ran via setsid on my laptop. Every lid close, every reboot — it stopped. The publish.bb script had a “check runner” step that tried to restart it, but the grep command would fail when no runner was found, crashing the whole publish workflow. Posts were committed and pushed but never deployed.

Failure 2 — Dual Hosting Confusion: The DNS had already been moved to Cloudflare. The _redirects file was already in the repo (a Cloudflare Pages convention). But the CI/CD pipeline still pushed to GitLab Pages as the “primary” with Cloudflare as a “fallback.” The mental model was split across two hosts. Half the deploy logic was irrelevant.

Failure 3 — Wrangler’s Preview Trap: When we ran npx wrangler pages deploy public --project-name=nurazhar --branch=main, it deployed to *.nurazhar.pages.dev — a preview URL — not nurazhar.com. Cloudflare Pages distinguishes between preview deployments (direct CLI uploads) and production deployments (Git-integrated triggers). We spent 24 hours thinking the deploy was working because curl returned 200 — but it was the old production deployment serving stale content.


The DevSecOps Mental Model: What We’re Actually Doing

Before diving into the migration, let’s establish the framework. As a DevSecOps engineer, you don’t just “move files.” You think in terms of:

Every migration decision maps to one of these five concerns. Let’s walk through each.


Concern 1: BUILD — What Changed?

Key insight: The old pipeline built the site TWICE — once locally (to validate), once on the runner (to produce the deploy artifact). The new pipeline builds once. The artifact is the same public/ directory. The only difference is how it reaches production.

SDLC principle: Every redundant step is a failure point. The self-hosted runner wasn’t adding value — it was re-running the same commands I’d already run. It existed solely because GitLab Pages required a CI artifact. Remove the requirement, remove the step.


Concern 2: DEPLOY — The Big Migration

What changed in the deploy path:

Stage Before After
Build Local + CI (2x) Local only (1x)
Transport git push + runner poll Direct HTTP upload
Hosting GitLab Pages CDN Cloudflare Pages CDN
DNS CNAME to GitLab Direct Cloudflare
Mirror Manual GitHub push Dropped

SDLC principle: The shortest path between build and production is the safest. Every intermediate system (runner, GitLab CI, Pages artifact upload) adds latency and failure modes. Direct deploy eliminates them all.


Concern 3: VERIFY — The 24-Hour Blind Spot

This was the critical failure. We verified the build, verified the preview URL, and assumed production was fine. But nurazhar.com was serving a stale deployment because:

  1. Cloudflare Pages distinguishes preview deployments (*.pages.dev) from production deployments (custom domain)
  2. The wrangler pages deploy command creates preview deployments by default
  3. Production is only updated by Git-integrated triggers — pushing to the connected Git branch

SDLC principle: Never verify against a different target than your users hit. The preview URL is a different deployment than production. Verifying against *.pages.dev tells you nothing about nurazhar.com.


Concern 4: SECURE — Attack Surface Comparison

Security improvements from the migration:

Threat Before After
Credential count GitLab PAT + runner token + CF API token = 3 Cloudflare API token only = 1
CI script execution Yes — runner runs arbitrary CI YAML No — no CI pipeline for deploy
Artifact tampering Runner → Pages upload (2 hops) Direct upload (1 hop)
Token scope GitLab PAT: full repo access CF token: pages:write only

SDLC principle: Every credential is a liability. Every hop between build and deploy is an interception point. Reducing 3 attack surfaces to 1 is a pure security win.


Concern 5: RECOVER — Rollback Story

Recovery is faster because there’s no CI queue, no runner polling interval, no artifact upload delay. You build and deploy directly. If something’s broken, you can revert and redeploy in under a minute.


The Complete New Workflow

Or use the one-liner: bb scripts/publish.bb <slug> — it runs all steps automatically.


What We Deleted: A Post-Mortem of Removed Components

The total removed: one daemon process, one CI job, one manual mirror push, and two secret variables. The .gitlab-ci.yml went from 120+ lines to 50 — a validation-only pipeline that checks the build without deploying.


The Files That Changed

File Change Reason
.gitlab-ci.yml Stripped to validate-only No more Pages/CD deploy
AGENTS.md Removed runner/github mirror refs New workflow docs
scripts/publish.bb Runner check → wrangler deploy Direct Cloudflare deploy
README.md Updated deploy instructions Reflect reality
File Change Reason
src/site/fabricate/dev/cli.clj Removed runner comment Clean code
src/site/fabricate/dev/sitemap.clj Updated _redirects comment CF Pages, not GitLab Pages
the-4-stage-chain-that-ships-this-site.md Added deprecation notice Historical accuracy
the-complete-cicd-architecture-of-a-solo-developers-blog.md Added deprecation notice Historical accuracy
forgotten-txt-record-broke-my-blog.md Added migration note GitLab Pages → CF Pages

The SDLC Principles This Migration Teaches

If you’re a DevSecOps engineer reading this: the migration wasn’t about moving files. It was about collapsing five stages into two commands, reducing three attack surfaces to one, and fixing a verification gap that left production stale for 24 hours. The code changes were trivial. The mental model shift was the real work.


Bottom Line

The old pipeline: local → git push → webhook → self-hosted runner polls → runner builds → runner uploads to GitLab Pages → CNAME → nurazhar.com. Plus a manual GitHub mirror push. Six stages, one daemon, two credentials, one redundant rebuild.

The new pipeline: local → bb buildnpx wrangler pages deploy → Cloudflare Pages → nurazhar.com. Two commands, one credential, one build. The Git push is source control only — it doesn’t trigger anything.

The migration took 30 minutes of editing. The debugging took 2.5 hours because I didn’t understand that wrangler pages deploy goes to preview URLs by default. The fix was trivial once we understood the architecture.

If you’re running a static site on GitLab Pages with a self-hosted runner: consider whether you still need either. Cloudflare Pages with direct wrangler deploy is faster, simpler, and has fewer failure modes. Your Git repo can be just source control — it doesn’t need to be your deploy pipeline too.


This post was deployed through the new pipeline it describes. The migration touched 9 files across the repo. The site now builds and deploys in ~30 seconds with bb scripts/publish.bb.