Publisync
RSS
Go & tooling

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:

  1. No missing file class of bug. You cannot deploy the code without the template.
  2. Fast startup. Parsing ~20 templates takes single-digit milliseconds.
  3. 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.

Aarav Mehta

Writes about performance engineering, Postgres and Go. Builds publishing systems that stay fast.

All posts by Aarav →