The CI/CD Migration That Killed My Blog for 24 Hours — A DevSecOps Teaching Case
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:
- Cloudflare Pages distinguishes preview deployments
(
*.pages.dev) from production deployments (custom domain) - The
wrangler pages deploycommand creates preview deployments by default - 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 build →
npx 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.