Every blog post about blogging tools risks being unbearably meta. I’m doing it anyway — because the stack behind this site is small enough to actually explain in one post, and the deployment pipeline has more validation than most production apps I’ve worked on.

This is how Crashloop is built, validated, and deployed. No CMS. No JavaScript frameworks. Just Hugo, a CI pipeline, and Cloudflare.

The Stack

  • Hugo — static site generator (v0.154.5)
  • Custom HTML/CSS — no external theme, just a handful of layout files
  • GitLab CI — four-stage pipeline (validate → build → test → deploy)
  • Cloudflare Pages — hosting and CDN
  • A couple helper scripts — one bash, one MCP server for Claude Desktop

That’s it. No database. No backend. No build step that takes longer than a few seconds.

Site architecture diagram showing content flowing through Hugo into static output hosted on Cloudflare Pages

Content in, static HTML out. The entire build pipeline in one picture.

Hugo Without a Theme

Most Hugo sites start with hugo new site and immediately pull in a theme. I went the other direction — custom layouts in the layouts/ directory and a single CSS file.

The entire layout system is five files:

layouts/
├── _default/
│   ├── baseof.html    # HTML skeleton, meta tags, Open Graph
│   ├── single.html    # Individual post view
│   └── list.html      # Post listing / index
├── partials/
│   ├── header.html    # Nav bar
│   └── footer.html    # Footer
└── 404.html

The base template (baseof.html) handles the HTML document structure, favicon, stylesheet, and Open Graph tags. Every page inherits from it. The single.html template renders a post with its title, date, reading time, tags, and content. The list.html template renders post cards with summaries.

There’s no template inheritance chain six levels deep. If I want to change how a post renders, I open one file.

Hugo Config

The entire site configuration fits in 40 lines:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
baseURL = "https://crashloop.derronknox.com"
languageCode = "en-us"
title = "Crashloop"
enableRobotsTXT = true

[params]
  author = "Derron Knox"
  description = "SRE and DevOps engineering — infrastructure patterns, homelab builds, and things that crash so you don't have to."
  tagline = "CrashLoopBackOff is just a starting point."

[markup.goldmark.renderer]
  unsafe = true

[markup.highlight]
  style = "dracula"
  lineNos = true
  lineNumbersInTable = true

A few things worth noting:

  • unsafe = true in the Goldmark renderer lets me embed raw HTML in markdown — needed for things like <video> tags and custom figure markup.
  • Dracula syntax highlighting with line numbers. Every code block in every post gets consistent styling.
  • No theme declaration. Hugo falls through to layouts/ when there’s no theme set, which is exactly what I want.

Post Structure

Posts live in content/posts/. Simple posts are single markdown files. Posts with images or video use Hugo’s page bundle format — a directory containing index.md and the media files:

content/posts/
├── terragrunt-multi-env.md                            # Single file
├── proxmox-cluster-build.md                           # Single file
└── multi-agentic-cloud-based-custom-prompt-grass-toucher/
    ├── index.md                                       # Post content
    ├── hero-robot.jpg                                 # Bundled with the post
    ├── garage-selfie.jpg
    └── grass-toucher.mp4

Every post starts with YAML frontmatter:

1
2
3
4
5
6
7
8
---
title: "How This Site Is Built and Deployed"
date: 2026-02-20
draft: true
summary: "A look under the hood..."
tags: ["hugo", "gitlab-ci", "cloudflare-pages"]
categories: ["infrastructure"]
---

The draft: true flag is the safety latch — it keeps posts out of the build until I’m ready to publish.

The CI Pipeline

This is where it gets interesting. The .gitlab-ci.yml runs four stages on every push to main and on every merge request:

CI/CD pipeline diagram showing four stages: validate, build, test, deploy

The four-stage pipeline. Pushes to main go to production; merge requests get preview URLs.

Stage 1: Validate Frontmatter

Before Hugo even runs, a lightweight Alpine container checks every non-draft post for required fields:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
validate:frontmatter:
  stage: validate
  image: alpine:latest
  script:
    - apk add --no-cache yq
    - |
      for f in content/posts/*.md; do
        [ "$(basename "$f")" = "_index.md" ] && continue
        frontmatter=$(sed -n '2,/^---$/{ /^---$/d; p }' "$f")
        draft=$(echo "$frontmatter" | yq '.draft')
        if [ "$draft" = "false" ]; then
          summary=$(echo "$frontmatter" | yq '.summary')
          tag_count=$(echo "$frontmatter" | yq '.tags | length')
          if [ -z "$summary" ] || [ "$summary" = "null" ]; then
            echo "ERROR: Empty summary in $f"
            failed=1
          fi
          if [ "$tag_count" = "0" ] || [ "$tag_count" = "null" ]; then
            echo "ERROR: Empty tags in $f"
            failed=1
          fi
        fi
      done

If you flip draft: false without filling in a summary or tags, the pipeline fails. No exceptions.

Stage 1b: Secret Scanning

Running alongside frontmatter validation, Gitleaks scans the entire repo for accidentally committed secrets — API keys, tokens, private keys, anything that looks like a credential:

1
2
3
4
5
6
7
validate:secrets:
  stage: validate
  image:
    name: zricethezav/gitleaks:latest
    entrypoint: [""]
  script:
    - gitleaks detect --source . --verbose --redact

This runs on every push to main and every merge request. Even for a static blog, this matters. The CI config references CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID — those live in GitLab’s masked CI variables, never in the repo. But if I accidentally paste a token into a post’s code block or a config example, Gitleaks catches it before it ever gets deployed. The --redact flag ensures findings in the CI log don’t leak the secret either.

“It’s just a blog” is not a reason to skip secret scanning. It’s a reason why it’s easy to add.

Stage 2: Build

Hugo builds the site with minification and garbage collection:

1
2
3
4
5
6
7
8
9
build:
  stage: build
  image: hugomods/hugo:exts-non-root-0.154.5
  script:
    - hugo --minify --gc --cacheDir /tmp/hugo_cache
  artifacts:
    paths:
      - public/
    expire_in: 1 hour

The public/ directory becomes an artifact for the next stages. Hugo modules are cached between runs so rebuilds are fast.

Lychee checks every internal link in the generated HTML:

1
2
3
4
5
6
7
8
link-check:
  stage: test
  image:
    name: lycheeverse/lychee:latest
    entrypoint: [""]
  script:
    - lychee --offline --root-dir public/ public/
  allow_failure: true

This runs in offline mode (no external HTTP requests) and is set to allow_failure — broken external links shouldn’t block a deploy, but I still want to know about them.

Stage 4: Deploy to Cloudflare Pages

The deploy stage pushes the built public/ directory to Cloudflare Pages using Wrangler:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
deploy:cloudflare:
  stage: deploy
  image: node:20-alpine
  script:
    - npm install -g wrangler@3
    - wrangler pages project list | grep -q "crashloop" ||
        wrangler pages project create crashloop --production-branch=main
    - wrangler pages deploy public/ --project-name=crashloop --branch=main
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

That first wrangler pages project list line is a nice trick — it auto-creates the Cloudflare Pages project if it doesn’t exist yet. Useful for bootstrapping a fresh environment without manual setup.

Merge Request Previews

MRs get their own preview deployment automatically:

1
2
3
4
5
6
7
mr-preview:
  stage: deploy
  script:
    - wrangler pages deploy public/ --project-name=crashloop --branch=$CI_COMMIT_REF_SLUG
  environment:
    name: preview/$CI_COMMIT_REF_SLUG
    url: https://$CI_COMMIT_REF_SLUG.crashloop.pages.dev

Every MR gets a unique URL like https://my-branch.crashloop.pages.dev where I can preview the post before merging. Cloudflare Pages handles this natively — no extra infrastructure needed.

Helper Scripts

new-post (Bash)

An interactive CLI that prompts for title, summary, tags, and category, then generates the markdown file with the right frontmatter and section skeleton:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ ./new-post

  Post Title
  > How This Site Is Built and Deployed

  Summary (one line, shows on post cards)
  > A look under the hood at the Hugo setup...

  Tags (comma-separated, existing: hugo, devops, terraform, ...)
  > hugo, gitlab-ci, cloudflare-pages, devops, blogging

  Category (existing: infrastructure, cloud, Blog)
  > infrastructure

  Created: content/posts/how-this-site-is-built-and-deployed.md

It slugifies the title, checks for duplicates, collects existing tags/categories from the repo for reference, and opens the file in $EDITOR if set.

MCP Server (Node.js)

There’s also an MCP server (mcp-server.mjs) that exposes create_post and list_posts tools to Claude Desktop. Same idea as the bash script, but conversational — I can tell Claude “create a new post about Kubernetes networking” and it generates the file directly.

The Publishing Flow

Publishing workflow diagram showing the lifecycle of a post from creation to deployment

The full lifecycle of a post — from ./new-post to live on the internet. Note the feedback loops.

The full lifecycle of a post:

  1. Create — Run ./new-post or ask Claude via the MCP server
  2. Write — Edit the markdown file. Posts start as draft: true
  3. Preview locallyhugo server -D serves drafts at localhost:1313
  4. Push to a branch — CI validates, builds, and deploys a preview
  5. Review — Check the preview URL, iterate
  6. Publish — Set draft: false, fill in summary and tags, merge to main
  7. Deploy — CI validates, builds, tests links, and pushes to Cloudflare Pages

The whole thing from commit to live takes about 90 seconds.

Why This Setup

I’ve used WordPress, Ghost, Jekyll, Gatsby, and at least three “I’ll just build my own” attempts. Hugo stuck because:

  • Speed — The site builds in under a second locally. The CI pipeline is fast because there’s nothing to install besides Hugo itself.
  • Simplicity — Five layout files, one CSS file, markdown content. No npm dependencies in the site itself. No client-side JavaScript.
  • Control — No theme to fight with. If the markup is wrong, I change it directly. The entire rendering layer is ~80 lines of HTML templates.
  • Portability — The output is static HTML. If Cloudflare Pages disappears tomorrow, I can host it anywhere. There’s even a commented-out rsync deploy job in the CI config for self-hosting on my Proxmox cluster.

The pipeline catches mistakes (missing summaries, broken links) before they go live, and MR previews let me see exactly what a post looks like before merging. It’s not fancy, but it works — and more importantly, it stays out of the way.


The source for this site is on GitLab. If you want to steal this setup, the CI pipeline and layout files are the interesting parts — the rest is just markdown.