The first post is, obligatorily, about the blog itself. What follows is the story of why it exists, how it is constructed, and the four moments during the build at which something broke in a way I was not expecting.
Motivation
I am a master procrastinator. I can start three projects in a week and finish none of them. What I have eventually discovered about myself is that I will, however, finish anything I have already told someone about. A blog post is a public commitment to a private piece of work; it moves a deadline out of my head and into a URL, where other people can observe its absence.
The blog exists for that reason, and for one other: I have learned things about my own code while drafting prose that I would not have learned by reading the code. The act of explaining surfaces gaps. It is a slightly unfair technique: you write a post about a project, the post reveals the project is not finished, and then you have to finish the project. But it is approximately the only trick I know that works on me reliably.
A blog is, in this framing, a forcing function disguised as a website.
Architecture
The stack is deliberately small. Every piece is something I would be willing to be paged for at 3 AM without consulting a second person first.
There are four moving parts. The web process is a single statically-compiled Go 1.25 binary, using html/template for rendering, gorilla/mux for routing, database/sql with lib/pq for Postgres, and goldmark for markdown. It reads from Postgres 16, which runs in a single-service Docker Compose project with a named volume and a published port bound to 127.0.0.1:5434 only. In front of both, cloudflared runs as its own systemd service and maintains a tunnel that speaks outbound QUIC and accepts nothing inbound; there are no ports open on the VPS's public interface for this site. The VPS itself is an AlmaLinux box that also runs a completely unrelated project, an ESP32 motor-temperature monitoring backend, and the two services share a kernel and nothing else.
Traffic flows in one direction. A browser talks to Cloudflare's edge; the edge talks to cloudflared over the tunnel; cloudflared talks to the Go binary on loopback; the Go binary talks to Postgres, also on loopback. If any hop in that chain fails, the site is unreachable, which is correct. No lower-quality fallback is being silently served.
A handful of properties of this arrangement are load-bearing and worth naming explicitly, because they are also easy to assume away.
Nothing on the VPS belonging to this site listens on a public interface. Every socket is bound to 127.0.0.1, and Cloudflare Tunnel gets its bits in via a QUIC connection the VPS initiates rather than one it accepts. The firewall has nothing to do on the blog's behalf.
The application is a single binary. CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" produces a 9.5 MB file, that file is the entire application, and deployment consists of scp, install, and systemctl restart. Things that can fail during a deploy are limited to those three verbs.
The binary holds no durable state. Migrations run at startup against whatever Postgres reports as its current schema version, which makes killing and restarting the process safe at any time.
The blog's tunnel, credentials, and systemd unit are all separate from the neighbor project's. Nothing about standing up this blog modifies a single file the other project owns, because shared fate is the enemy of uptime.
Design choices
Why Go, and not a CMS
WordPress was considered briefly and dismissed. Ghost, Hugo, Jekyll, and several static-site generators were considered more seriously. Go from scratch won anyway. The blog will eventually have comments, which means a backend and a database, and static generators do not solve this for free. A CMS would, but a CMS is a useful tool for people who want to write, not for people who want to know what is happening when they write, and I fall in the second category. A single static binary is also a deployment target I can reason about, which is not something that can be said of a PHP application with twenty plugins.
The cost is that I wrote a blog engine. The benefit is that I wrote a blog engine. These are the same fact viewed from two directions.
Why Cloudflare Tunnel, and not nginx with Let's Encrypt
The VPS already runs an nginx for other projects. Adding a virtual host for the blog would have been the smallest possible change. I chose the tunnel anyway, because the smallest possible change is rarely the best one, and the incremental effort of picking up the tunnel pays out in several directions. TLS termination happens at Cloudflare's edge, which means I maintain no certificates, rotate no keys, and run no renewal cron at 3 AM. No ports on the VPS are open to the public; the firewall rule for port 80 is someone else's problem, and this site needs port 80 to do nothing, and port 443 to do nothing, and nothing else in between. DDoS mitigation and bot filtering come with the tunnel at no additional cost. Subdomain and path routing reduces to a YAML edit rather than an nginx reload.
The tradeoff is that I now depend on Cloudflare for availability. Cloudflare has had multi-hour global incidents in its history. I will live with this.
Comments without accounts
Users cannot register. There is no login. Anyone can submit a comment; every comment is moderated before it appears. The reasoning is straightforward: for a blog that will, in its most optimistic forecast, receive tens of comments per month, an authentication system would consume more maintenance attention than the comments themselves are worth. Moderation scales fine at human speed up to several orders of magnitude more traffic than I will ever receive.
Anti-spam is layered, because any single layer will eventually fail:
- Bot Fight Mode at Cloudflare's edge filters known bot fingerprints before they reach the service.
- Honeypot. The comment form includes a hidden
websitefield, invisible to sighted users, that legitimate browsers will not fill. Bots that auto-complete every text input will. The server accepts those submissions and silently discards them. - Rate limiting at 10
POST /api/*per minute per real client IP, read from theCF-Connecting-IPheader rather thanr.RemoteAddr(the latter, when you are behind a same-host reverse proxy, is127.0.0.1for everyone and therefore useless). - Moderation. Nothing appears publicly until I approve it.
The layers have independent failure modes, which is the only reason stacking them helps.
Admin authentication, delegated
The /admin routes moderate comments and must not be reachable by the public. The first version of this code shipped with no authentication at all and a warning banner in the template reading "add auth before deploying to production."
I could have added auth middleware in Go. I did not. /admin* is gated at the Cloudflare edge by a Cloudflare Access policy: unauthenticated requests to the path never reach the Go process. Authentication is a one-time PIN sent to a single allow-listed email.
Reasons:
- I am one admin. Any auth system I write will have a single user and will be over-engineered in proportion.
- Default-deny-unless-explicitly-allowed is the policy model I want, and Cloudflare's Access dashboard happens to implement it.
- Brute-force attempts never touch my backend. They're answered by Cloudflare's edge, with my service none the wiser.
- The policy is auditable in a dashboard rather than buried in a Go middleware that I would have to remember exists.
The tradeoff is that a Cloudflare outage means I cannot moderate comments during the outage. Since approval is not time-sensitive, this is fine.
Systemd sandbox
The Go process runs as an unprivileged system user under a systemd unit that drops capabilities and restricts syscalls. ProtectSystem=strict makes the filesystem read-only to the process except for the paths it needs. ProtectHome=yes makes user home directories invisible. PrivateTmp, PrivateDevices, the various Protect* toggles, and RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX close the usual off-ramps. CapabilityBoundingSet= is empty. SystemCallFilter=@system-service is a seccomp allowlist.
This is the principle of least privilege applied with a checklist. It is not a guarantee of anything. It is a reduction of blast radius. A hypothetical RCE in the Go server has fewer places it can go than it would in a process running as root with an open filesystem.
Debugging notes
Four things broke during the build. Each was instructive.
1. The systemd unit crashed with SIGSYS on every start
The first attempt to start the Go service ended with the process dying immediately: code=dumped, signal=SYS. SIGSYS is what the kernel sends a process when its seccomp filter has denied a syscall.
The unit had both of these lines:
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
I had treated them as additive: "allow the system-service group, and additionally deny privileged and resources." That is not how systemd's syscall filter behaves. @system-service is a permissive allowlist that already includes @resources. The second line then subtracted @resources from the allowed set. Go's runtime calls sched_getaffinity on startup, which lives in @resources, which was no longer allowed. The process was dead before main().
The fix was to delete the second line. @system-service is Red Hat's considered opinion on what a well-behaved service needs; appending a denylist on top of it amounts to second-guessing that opinion with no data to support the guess.
Lesson: a seccomp filter is set arithmetic, not layered policy. The operator that does not exist in syntax is the one I reached for first.
2. /admin returned 404 through Cloudflare Access
After wiring up the Access application and successfully authenticating, the very next step (loading the page I had just authenticated for) returned 404.
The router was configured with:
admin := s.router.PathPrefix("/admin").Subrouter()
admin.HandleFunc("/", s.handleAdminDashboard).Methods("GET")
In gorilla/mux, this matches /admin/ with a trailing slash, but not /admin without. The URL I was testing was /admin. The router had no handler for that exact path; the default response was 404.
The non-trivial part of this bug was not the fix (three lines of Go to register both paths) but the isolation. Cloudflare was serving the 404 page verbatim, which made it easy to suspect Cloudflare Access of the crime. Cloudflare was in fact proxying the Go server's 404 faithfully.
Lesson: when you add a layer between the user and your application, keep your old debugging affordances. journalctl -u enginerd.service -f would have produced the answer in ten seconds. The minutes I spent before reaching for it are the minutes I intend to spend fewer of next time.
3. Every page rendered the Projects page
After the initial template port, the landing page, the blog index, the about page, and the projects page all rendered the contents of templates/projects.html. The <title> tag varied per page, which is how I eventually realized the bug was in the template layer and not the router.
Go's html/template.ParseGlob loads every matching file and registers every {{define}} in them into a single namespace. When multiple files define a block with the same name, the last one loaded wins. All of my per-page templates used {{define "content"}}. ParseGlob loaded them alphabetically. projects.html sorts after post.html, index.html, blog.html, about.html, and admin.html. Projects won.
The fix was to load each page as its own separate template set containing base.html plus exactly one page file. The landing page is now unambiguously the landing page, because it is the only "content" definition in its template set.
Lesson: Go's template system resembles a single global namespace more than it resembles a file-scoped compilation unit. Acting otherwise will surprise you, probably at a bad time. Also, a < b < ... < p < q is a very quiet dependency.
4. CSS changes did not appear after deploy
I changed a stylesheet, rsync'd, restarted the service, hard-refreshed the page, and saw no change. Cloudflare's edge was serving the stylesheet with cf-cache-status: HIT and approximately four hours of TTL remaining. Ctrl+Shift+R bypassed my browser's memory cache but not the edge cache. I purged the specific URL via the Cloudflare dashboard, and the change appeared.
Later, even after the purge, the page rendered the old CSS in my primary browser. Opening the same URL in an incognito window showed the new CSS. Browser disk cache had the old file and was refusing to ask the network about it. Hard refresh is supposed to bypass disk cache; in this instance it did not.
Lesson: "cache" in a modern web stack is not one thing. There is my browser's memory cache, its disk cache, service workers I did not write, Cloudflare's edge, network middleboxes, and so on. When the deployed file and the rendered output disagree, every one of them is a hypothesis, and most are hard to falsify directly. The fix that makes this go away permanently is versioning the URL itself (for example style.css?v=7), so that "cache invalidation" stops being a thing I do and starts being a thing I avoid by renaming.
Deliberately omitted
Things this blog does not have, and is better for their absence:
- No JavaScript framework. The site ships about 70 lines of vanilla JS, which handle exactly one thing: submitting the comment form without a page reload. There is no React, no Vue, no htmx, no bundler, no JSX, no hydration, no web component.
- No CMS. Posts are markdown files in a directory, committed to git. The binary reads them at startup.
- No auth middleware in the Go code. Admin is gated at the edge.
- No Docker image for the Go service. The binary runs under systemd directly. Postgres is in a container because the Postgres maintainers publish a better image than I would have assembled.
- No SSR-to-hydration handshake. HTML is rendered server-side and delivered to the browser, which displays it. That this is worth noting in the 2020s is itself somewhat interesting.
- No service worker. Aside from the comment form, the site works with JavaScript disabled.
- No tracking. No Analytics, no Plausible, no anything. I do not want to know.
- No RSS feed. This is an oversight, not a design choice.
The exercise of enumerating what is not present is a useful forcing function: anything on the list could be added later, and the justification for its absence has to be stronger than "I forgot."
Coexisting with another service
The VPS hosting this blog also hosts an ESP32 motor-temperature monitoring system, which is unrelated to anything in this post. The two projects share a kernel, a filesystem root, and a public IP address. They share nothing else. This was a constraint, not an accident.
The blog's Postgres runs as its own Compose project, with its own container name, its own network, and its own named volume. It binds to 127.0.0.1:5434; the neighbor's Postgres binds to 127.0.0.1:5433. Neither knows the other is there. The blog has its own cloudflared systemd service, its own tunnel, and its own credentials file. The neighbor's tunnel and credentials live elsewhere, untouched. If you diff the neighbor's files before and after I stood this blog up, the diff is empty. firewalld was not touched either, because the blog requires no new rules; every listening socket is loopback-only, and traffic arrives via an outbound QUIC connection from cloudflared.
If this blog suffers an incident, the damage is the blog. Logs, state, credentials, tunnel, network, and binary are all separable from the neighbor's. Nothing belonging to the other service can be reached through anything belonging to this one.
This is routine practice at a certain kind of company. It is also routine practice worth writing down, because the alternative, "we'll toss them on the same box, it'll be fine," has a tendency to end at a 2 AM page for a service nobody remembered was running.
Numbers
| Metric | Value |
|---|---|
| Binary size | 9.5 MB |
| Memory, RSS, steady state | ~15 MB |
Cold start, systemctl start to first served request |
~125 ms |
| Direct Go module dependencies | 5 |
| Lines of Go, excluding tests | ~1,050 |
| Lines of CSS | ~950 |
| Database tables | 3 |
| HTTP ports reachable from the public internet | 0 |
None of these numbers are impressive. That is the point. The blog is small because it needed to be small, and no more.
What's next
A running list of outstanding items lives on my machine. The short form:
- Daily
pg_dumpon a systemd timer, 14-day retention. - Versioned CSS URLs, so the debugging story in section four of this post stops repeating itself.
HEADrequests on page routes, so external uptime monitors stop getting 405s.- Actually write more posts.
The first and last items on that list are at the top and bottom by accident, but the accident has a certain fitness to it.
Comments (0)
No comments yet. Be the first to comment.
Leave a Comment