VPS part 4: Building posts.monoexp.com
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.
The stack
| 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 |
What's in the repo
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.
Pages
| 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)
A post is a file
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.
How a request is served
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.
Security choices
The site is small, but it's public, so it's careful by default:
-
URLs never become file paths.
/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.pngfiles in a known release folder. -
No raw HTML in posts. goldmark drops it by default, so a post can only contain what Markdown can express.
html/templateescapes 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.
-
Server timeouts for reading headers, reading requests, writing responses and idle connections, so slow clients can't hold connections open forever.
-
Defence in depth from part 1: the app listens only on
127.0.0.1, runs as the unprivilegedappsuser, and can't write anywhere except its own emptydata/folder.
Settings without a rebuild
Two kinds of settings, kept apart:
-
Server settings go in environment variables from
/etc/goapps/posts.env:PORT,CONTENT_DIR, andSITE_URLfor 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.
Small details
- Stylesheet cache busting. Pages link to
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. - Version and commit on the About page. The version is a constant
bumped in each release. The commit comes from the git information
go buildrecords in the binary, so the About page always says exactly which code is running. - RSS authors. RSS's
<author>field must be an email address, so the feed uses the Dublin Core<dc:creator>element for names.
Deploying it
The blog uses everything from parts 1 and 2 without any special cases:
- runs as
goapp@postson port 8002, with its env file - an nginx file proxying
posts.monoexp.comto127.0.0.1:8002 - deployed with
deploy-go posts --content, which builds the binary and syncscontent/
How it grew
| 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.