How This Site Is Built and Deployed
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.
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:
| |
A few things worth noting:
unsafe = truein 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:
| |
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:
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:
| |
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:
| |
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:
| |
The public/ directory becomes an artifact for the next stages. Hugo modules are cached between runs so rebuilds are fast.
Stage 3: Link Check
Lychee checks every internal link in the generated HTML:
| |
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:
| |
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:
| |
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:
| |
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
The full lifecycle of a post — from ./new-post to live on the internet. Note the feedback loops.
The full lifecycle of a post:
- Create — Run
./new-postor ask Claude via the MCP server - Write — Edit the markdown file. Posts start as
draft: true - Preview locally —
hugo server -Dserves drafts atlocalhost:1313 - Push to a branch — CI validates, builds, and deploys a preview
- Review — Check the preview URL, iterate
- Publish — Set
draft: false, fill in summary and tags, merge tomain - 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.