Every couple of years I’d decide it was finally time to start a blog. I’d spend an evening evaluating static site generators, get as far as configuring a build pipeline, and then quietly close the laptop and move on with my life. The writing was the part I wanted to do. The yak-shaving was not.
Static generators are good tools — I’m not here to knock Hugo or Jekyll. But they require a build step. You write markdown, run a command, and it produces HTML that you deploy somewhere. That means a CI pipeline, artifact storage, a deployment target, and a rebuild every time you fix a typo. For a personal blog, that’s a lot of ceremony between “I finished writing” and “it’s live.”
CMS platforms solve the build step problem by introducing a different set of problems: a database, an admin panel, a plugin ecosystem, a security surface area that needs patching, and a deployment that’s more complex than the content it serves. I’ve spent enough of my career operating systems like that. I didn’t want to operate one for my blog.
What I actually wanted was simple: write markdown files, put them in a git repo, and have a running server turn them into a blog. No build step, no database, no admin panel. One binary.
So I built one.
Why a new blog engine
BlogFlow is a blog engine written in Go. It’s a single binary that takes a directory of markdown files with YAML front matter and serves them as a blog. The design goals were specific:
- No build step. Content is parsed and rendered at runtime, not compiled into static HTML. You push a markdown file, the site updates.
- Container-native from the start. Not a desktop application shoehorned into Docker after the fact, but something designed to run as a container from day one — health probes, metrics, structured logs, rootless execution, the works.
- Zero required configuration. BlogFlow ships with embedded defaults for everything: templates, CSS, error pages, config values. You can run it with nothing but a folder of markdown files and get a working site.
- One binary, one concern. It serves a blog. It doesn’t manage users, moderate comments, resize images, or send newsletters. Scope discipline is a feature.
The name reflects the idea: content flows through the system without friction. You write, you push, it’s live.
I’d spent years building and operating container services at scale — at EA running game infrastructure, at Microsoft building observability and commerce platforms. The patterns that make those systems reliable (small binaries, health probes, observable behavior, immutable deployments) are the same patterns that make a good blog engine. BlogFlow is what happens when you apply production infrastructure thinking to a problem that usually gets solved with WordPress.
The overlay filesystem
This is the architectural idea I’m most pleased with, and it’s the thing that makes BlogFlow feel different from other engines I’ve used.
BlogFlow ships with a complete set of embedded defaults — templates, CSS, static assets, error pages, even a default site configuration. These are compiled into the binary using Go’s embed package. When BlogFlow starts, it builds a layered filesystem that stacks external files on top of these embedded defaults:
block-beta
columns 1
A["Your theme overrides — theme/templates/, theme/static/"]:1
B["Your content — content/posts/, content/pages/"]:1
C["Your config — config/site.yaml"]:1
D["Embedded defaults — compiled into the binary"]:1
style A fill:#22d3ee,color:#0f172a
style B fill:#1e90ff,color:#0f172a
style C fill:#6366f1,color:#fff
style D fill:#334155,color:#94a3b8
Files resolve top-down — highest priority at the top, embedded defaults at the bottom.
The overlay resolves files top-down. If you create theme/static/css/main.css, BlogFlow serves your file. Every other static asset — fonts, images, JavaScript — falls through to the embedded defaults. If you override theme/templates/post.html, your template renders posts. Every other template uses the built-in version.
This means you only create files you want to customize. There’s no “eject” step where you dump the entire theme into your project and own all of it. You override the pieces you care about and the defaults handle the rest.
For this site, I override the CSS and a handful of templates. Everything in the theme directory is optional — delete it entirely and BlogFlow still serves a fully functional blog using its embedded defaults. Add files back one at a time as you customize. You can see exactly what I override in the deployment section below.
Under the hood, the overlay is built on Go’s io/fs.FS interface — the same abstraction the standard library uses for filesystem operations. It’s clean, testable, and there’s no magic. Each layer is just an fs.FS implementation, and the overlay walks them in priority order to resolve each file path.
Content and git sync
Content is markdown with YAML front matter — the same format used by Hugo, Jekyll, and every other static generator. A post looks like this:
---
title: "Your Post Title"
slug: "your-post-title"
date: 2026-03-30
tags: ["go", "containers"]
description: "A short description for feeds and social sharing."
---
Your content in Markdown. Standard stuff.
BlogFlow scans the content directory at startup, parses every markdown file, and builds an in-memory index of posts and pages. The scanner uses best-effort mode: if one post has malformed front matter — a missing date field, a YAML syntax error — BlogFlow logs the problem and skips that file. The rest of the site keeps working. This matters more than you’d think. Nothing kills the motivation to write like pushing a new post and watching the entire site go down because you forgot a closing quote in the front matter.
The more interesting part is how content stays in sync after startup. BlogFlow supports four sync strategies, configured in site.yaml:
sync:
strategy: "poll"
repo: "https://github.com/khaines/khainesnet-web.git"
branch: "main"
poll_interval: "60s"
| Strategy | How it works | Best for |
|---|---|---|
| watch | Filesystem watcher detects changes | Local development |
| poll | git pull on a timer interval |
Simple production setups |
| webhook | GitHub/GitLab webhook triggers a pull | Instant updates |
| sidecar | Watches a path synced by an external tool | Kubernetes git-sync sidecar |
This site uses poll with a 60-second interval. I push a commit to the content repo, and within a minute BlogFlow pulls the change, rescans, and serves the updated content. No container rebuild. No redeployment. No CI pipeline for content changes. The workflow is just git push.
The sidecar strategy deserves a mention because it’s the Kubernetes-native approach. Instead of BlogFlow managing git operations itself, you run a git-sync sidecar container that clones the repo into a shared volume. BlogFlow watches that volume for changes. This separates concerns cleanly — git-sync handles authentication and git operations, BlogFlow handles content serving — and it’s the pattern I used before adding built-in git support to BlogFlow.
What BlogFlow is good at
Zero to running blog in seconds. Download the binary, point it at a folder with one markdown file, run it. The embedded defaults give you a working site with a responsive theme, an Atom feed, syntax highlighting, and proper HTML structure. No config file required. Add a site.yaml when you want to customize the title, description, or behavior.
Container-native operations. BlogFlow exposes /healthz and /readyz endpoints for liveness and readiness probes. Prometheus metrics are served on a dedicated port (9090 by default) so they’re never exposed to public traffic. OpenTelemetry traces and structured logs give you full request-level observability. The official container image is built on a distroless base — no shell, no package manager, nothing except the binary. It runs as UID 65532 (the nonroot user) and the compressed image is under 25MB.
Live content updates. This is the thing that makes the day-to-day experience pleasant. I write a post in my editor, commit, push, and it’s live. There’s no “deploy content” step. The sync strategy handles it. If I spot a typo after publishing, the fix is a one-line commit — not a rebuild and redeploy.
Minimal attack surface. The distroless base image means there’s nothing to exploit even if someone gets into the container. No shell to spawn, no package manager to abuse. The binary runs rootless. Content Security Policy headers are strict by default — no inline scripts, no eval, explicit allowlists for external resources. Security headers like X-Content-Type-Options, X-Frame-Options, and Strict-Transport-Security are baked in, not bolted on.
The overlay model. This is the thing I keep coming back to — it solves the all-or-nothing theme customization trap I’ve hit with every other blog engine. Customize one CSS file, leave the rest alone. When BlogFlow ships a template improvement, you get it automatically — unless you’ve overridden that specific template.
What BlogFlow is NOT
I’ve been around long enough to know that the most useful thing a project maintainer can do is be honest about what their tool doesn’t do. So here’s the list.
It’s not a CMS. There’s no admin panel, no WYSIWYG editor, no media library, no user management. You write markdown in whatever editor you prefer and commit it to git. If you want a visual editing experience — a rich text editor in a browser, drag-and-drop image uploads, scheduled publishing with a calendar view — BlogFlow is not the tool. That’s a fundamentally different kind of software, and trying to be both would make it worse at the thing it’s actually good at.
It’s not a static site generator. There’s no blogflow build command that outputs a directory of HTML files. BlogFlow is a running server — it starts, it listens, it serves requests. If your hosting model is “upload HTML to GitHub Pages” or “deploy to Netlify’s free tier,” this won’t work. You need somewhere to run a container or a binary.
There’s no plugin system. The feature set is what ships in the binary. You can customize templates and CSS through the overlay, but you can’t extend the engine’s behavior with Go plugins, Lua scripts, or webhook-triggered extensions. This is deliberate. Plugin systems are a complexity vector — they require a stable API surface, security boundaries, versioning, and documentation that’s harder to maintain than the feature itself. BlogFlow does less, but what it does is predictable.
No full-text search. You can browse by tags and categories, and there’s an Atom feed, but there’s no search box. This is on the roadmap — likely client-side with a pre-built index — but it’s not there today.
No built-in comments. BlogFlow serves content. It doesn’t manage user-generated content, authenticate visitors, or moderate discussions. If you want comments, you’d integrate an external service. That’s a deliberate boundary — comment systems are a security and moderation surface area that has nothing to do with serving blog posts.
It’s not for everyone. If you need a multi-author CMS with editorial workflows, role-based access, and a database — use WordPress, Ghost, or something purpose-built for that. BlogFlow is for the person who wants a simple blog that runs well in a container. That’s a narrow audience, and I’m fine with that. Tools that try to serve everyone tend to serve no one particularly well.
This site runs on it
kenhaines.net is a BlogFlow deployment on Azure Container Apps. The setup is straightforward and it’s the same architecture I’ve described in this post — no demo-mode shortcuts.
The container image is built from a minimal Dockerfile that copies in the site config and theme overrides. Content syncs via the poll strategy, pulling from a private GitHub repo every 60 seconds — BlogFlow supports authenticated git clones for exactly this use case. The theme uses the overlay filesystem — custom CSS and a handful of template overrides on top of BlogFlow’s embedded defaults:
graph LR
subgraph site ["kenhaines.net"]
css["CSS ✎"]
templates["Templates ✎"]
partials["Partials ✎"]
end
site -->|overrides| overlay["Overlay FS"]
subgraph embedded ["BlogFlow Defaults"]
images["Images"]
js["Mermaid JS"]
config["Default config"]
end
embedded -->|falls through| overlay
overlay --> served["What gets served"]
style css fill:#22d3ee,color:#0f172a
style templates fill:#22d3ee,color:#0f172a
style partials fill:#22d3ee,color:#0f172a
style images fill:#334155,color:#94a3b8
style js fill:#334155,color:#94a3b8
style config fill:#334155,color:#94a3b8
style overlay fill:#6366f1,color:#fff
style served fill:#10b981,color:#0f172a
Ten files overridden, six defaults untouched — that’s the overlay in practice.
For observability, OpenTelemetry traces go to Azure Application Insights, Prometheus metrics go to Azure Monitor, and I have Grafana dashboards for request latency, cache hit rates, and content sync status. It’s the same observability stack I’d use for a production service at work, scaled down to a blog that gets modest traffic.
The whole thing — from pushing a markdown file to seeing it live on the site — takes about a minute. That’s the experience I wanted when I started this project: write, push, done. No build step, no deploy pipeline, no friction between having a thought and publishing it.
BlogFlow is open source and MIT-licensed. The code is at github.com/khaines/blogflow. If any of this sounds like the kind of blog engine you’ve been looking for, give it a look. If it doesn’t — that’s fine too. There are 10,000 blog engines for a reason.