One binary, one database, zero runtime
A Go binary with embedded templates, embedded assets and an embedded migration runner is the smallest deployable web app that is still a real web app. Here is the shape of it.
The deploy story for this site is: copy a 14 MB binary to a server, set two environment variables, run it. There is no build step at serve time, no node_modules, no reverse proxy config to keep in sync with a framework’s routing conventions.
Embedding is not a gimmick
go:embed turns templates, CSS and JS into read-only data inside the binary. That buys three things:
- No missing file class of bug. You cannot deploy the code without the template.
- Fast startup. Parsing ~20 templates takes single-digit milliseconds.
- Atomic rollback. Old binary, old assets. No half-deployed CSS.
//go:embed templates/*.html templates/admin/*.html assets/*
var assetsFS embed.FS
Templates are parsed once at boot, not per request. html/template is safe by construction — contextual escaping per insertion point — which is why the markdown layer can hand it sanitized HTML without a second thought.
Migrations belong in the binary too
A migrate package that reads embedded .sql files, sorts them, and records what it applied in a schema_migrations table is about forty lines. It runs before the server starts listening, inside a transaction per file. No separate migration container, no “did the pod that runs migrations come up first” problem.
Request handling without a framework
Go 1.22’s pattern matching covers what a router does for an app this size:
mux.HandleFunc("GET /{slug}", s.bySlug)
mux.HandleFunc("GET /admin/posts/{id}", s.guard(s.postEdit))
mux.HandleFunc("POST /admin/posts/{id}/status", s.guard(s.postStatus))
Method-aware, wildcard-capable, and it returns a 405 for the wrong verb without extra code. The guard wrapper composes authentication and CSRF in one place, so a new admin endpoint is safe by default rather than safe if you remember.
What composability looks like
Middleware is a chain of func(http.Handler) http.Handler: recover → security headers → session → cache. Each one is testable alone. The cache layer sits closest to the mux because it must see the final rendered bytes — and because a cached response should cost less than a session lookup.
The honest downside
You write more glue than you would with a framework. Sessions, CSRF, pagination, form parsing — all hand-rolled, all small, all yours to debug. That trade is good exactly when the app is mostly rendering and barely interactive. It is a bad trade for a dashboard with forty screens. Choose per surface: this project renders text on one side and gives the admin console a separate, heavier set of templates on the other.