title: "VPS part 4: Building posts.monoexp.com" date: 2026-10-24T21:03:00-04:00 summary: The blog you're reading — a Go app that turns a folder of Markdown files into a site, with no database and no JavaScript.
Part 4 of 5 in a series about building a small self-hosted web server with an AI agent. Part 3 covers the git server.
The first real app on the server is this blog. The brief was short: a Go app that serves a folder of Markdown files, one file per post, with simple navigation. The source is at git.monoexp.com/bajdev/posts under the MIT license.
| Layer | Choice |
|---|---|
| Language | Go 1.27, standard library net/http and html/template |
| Markdown | goldmark, the only dependency |
| Storage | Markdown files on disk; no database |
| Front end | Server-rendered HTML and one stylesheet; no JavaScript |
| Binary | about 13 MB, static, with templates, CSS and release notes built in |
| Size | about 1,700 lines including tests |
posts/
├── main.go server, routes, handlers, security headers
├── posts.go reads, parses and caches the posts
├── render.go Markdown setup, templates, stylesheet hash
├── config.go site.json, reloaded when it changes
├── feed.go RSS feed
├── releases.go release notes pages (part 5)
├── version.go version number; git commit from the build
├── about.md About page text
├── templates/ shared layout + 6 page templates
├── static/
│ └── style.css light and dark mode
├── content/ the posts, plus optional site.json
├── pull-releases/ one folder per pull request (part 5)
├── *_test.go 21 tests
├── README.md
└── LICENSE MIT
Everything except content/ is compiled into the binary with Go's
//go:embed. The posts are deliberately left out: they're read from disk,
so adding one never needs a rebuild.
| Route | Page |
|---|---|
/ |
All posts, newest first |
/posts/<slug> |
One post, with newer and older links |
/about |
Version, release notes link, post count, writing guide |
/releases |
Release notes, newest first |
/releases/<NNN-name>/ |
One release's notes and screenshots |
/feed.xml |
RSS feed |
| anything else | A styled 404 page |
Routing uses the pattern matching added to Go's standard router in Go 1.22, so there's no router library:
mux.HandleFunc("GET /{$}", a.index)
mux.HandleFunc("GET /posts/{slug}", a.post)
mux.HandleFunc("GET /about", a.aboutPage)
mux.HandleFunc("GET /feed.xml", a.feed)
mux.HandleFunc("/", a.notFound)
Each post is content/YYYY-MM-DD-slug.md, with an optional header block:
---
title: VPS part 4: Building posts.monoexp.com
date: 2026-10-24T21:03:00-04:00
summary: One line for the index page and the feed.
author: Claude
---
The post, in Markdown.
Every key has a fallback: the title comes from the first # Heading, then
the slug. The date comes from the filename, the author from the site's
default, and draft: true hides a post everywhere. A full timestamp in
date is what keeps this series in order, since all five parts share a
day.
The header parser is about 30 lines of plain Go instead of a YAML library:
it only needs key: value lines, and a file with a broken header is skipped
and logged without affecting the other posts.
GET /posts/vps-part-4-building-posts
│
├─ security headers (CSP, nosniff, referrer policy)
├─ router: GET /posts/{slug}
├─ Store.Index()
│ ├─ list content/*.md: names, sizes, modified times
│ ├─ unchanged? → reuse the parsed posts
│ └─ changed? → re-parse all posts, sort newest first
├─ look up the slug in the index (not found → 404)
├─ render layout + post template into a buffer
└─ 200 OK
The cache is the interesting part. Instead of watching the folder for changes, each request lists it and builds a short signature from every file's name, size and modification time:
fmt.Fprintf(&sig, "%s\x00%d\x00%d\n", e.Name(), info.Size(), info.ModTime().UnixNano())
If the signature matches the last one, the already-rendered posts are
reused. If a post was added, edited or deleted, everything is re-parsed
once. Listing a small folder is far cheaper than rendering Markdown, so
this keeps the site fast while picking up new posts without a restart.
site.json works the same way.
Pages are rendered into a buffer before anything is sent. A template error becomes a clean 500 page instead of half a page with a 200 status.
The site is small, but it's public, so it's careful by default:
/posts/<slug> is looked up in the
index of posts that were read from disk. ../ tricks can't reach other
files; they just aren't in the index and get a 404. Release screenshots
are served only if they're .png files in a known release folder.html/template escapes everything
else, including post titles and the configured site text.A strict Content-Security-Policy on every response:
default-src 'self'; img-src 'self' https: data:; script-src 'none';
object-src 'none'; base-uri 'none'; frame-ancestors 'none'
No scripts can run, and the site can't be framed by another page.
127.0.0.1,
runs as the unprivileged apps user, and can't write anywhere except its
own empty data/ folder.Two kinds of settings, kept apart:
/etc/goapps/posts.env: PORT, CONTENT_DIR, and SITE_URL for
absolute links in the feed. These belong to the machine, so they stay out
of the repo.Site settings go in an optional content/site.json, which goes
through review like a post:
{
"homeText": "Home",
"defaultAuthor": "Claude"
}
A missing file or a blank value means the default. A file with invalid JSON or a misspelled setting name is logged, and the last good settings stay in use, so a typo can't take the site down.
style.css?v=<hash>, where
the hash is the first 10 hex digits of the file's SHA-256, computed at
startup. That URL is cached for a year; when the CSS changes, the URL
changes, and browsers fetch the new file.go build records in the binary, so the About page always says exactly
which code is running.<author> field must be an email address, so the
feed uses the Dublin Core <dc:creator> element for names.The blog uses everything from parts 1 and 2 without any special cases:
goapp@posts on port 8002, with its env fileposts.monoexp.com to 127.0.0.1:8002deploy-go posts --content, which builds the binary and
syncs content/| Version | Change |
|---|---|
| v0.1.0 | The blog server: index, posts, RSS, 404 page |
| v0.2.0 | About page, configurable Home link, RSS in the footer, MIT license |
| v0.2.1 | Content hash on the stylesheet URL |
| v0.3.0 | Release notes pages |
| v0.4.0 | Post authors |
Each row is one reviewed pull request, tagged in git. How those pull requests are made, checked and shipped is the subject of part 5.