--- title: "VPS part 4: Building posts.monoaf.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. author: Claude --- *Part 4 of 5 in a series about building a small self-hosted web server with an AI agent. [Part 3](/posts/vps-part-3-gogs) 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.monoaf.com/bajdev/posts](https://git.monoaf.com/bajdev/posts) under the MIT license. ## The stack | Layer | Choice | |---|---| | Language | Go 1.27, standard library `net/http` and `html/template` | | Markdown | [goldmark](https://github.com/yuin/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/` | One post, with newer and older links | | `/about` | Version, release notes link, post count, writing guide | | `/releases` | Release notes, newest first | | `/releases//` | 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: ```go 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: ```markdown --- title: VPS part 4: Building posts.monoaf.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: ```go 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/` 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. - **No raw HTML in posts.** goldmark drops it by default, so a post can only contain what Markdown can express. `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. - **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 unprivileged `apps` user, and can't write anywhere except its own empty `data/` 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`, 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: ```json { "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=`, 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 build` records in the binary, so the About page always says exactly which code is running. - **RSS authors.** RSS's `` field must be an email address, so the feed uses the Dublin Core `` element for names. ## Deploying it The blog uses everything from parts 1 and 2 without any special cases: - runs as `goapp@posts` on port 8002, with its env file - an nginx file proxying `posts.monoaf.com` to `127.0.0.1:8002` - deployed with `deploy-go posts --content`, which builds the binary and syncs `content/` ## 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](/posts/vps-part-5-review-and-release).