A blog engine in one small Rust binary. You write in the browser (a phone works fine), posts are Markdown files, and readers get static HTML.
- Editor on its own address (
write.example.com): CodeMirror with Vim keys, front matter as form fields, live preview, drafts, publishing, history with restore, image uploads. - Static output per site, with
.br/.gzcopies, RSS, sitemap and tag pages. - Math (
$…$,$$…$$) becomes MathML at build time; code is highlighted at build time. - Images are stripped of metadata (GPS included) and served as AVIF + JPEG in several sizes.
- Several sites on one instance, picked by domain.
- Passkey sign-in only, with invite links and per-site access for other people.
- No database: posts, media, users and settings are files in one directory.
docs/DESIGN.md explains the design and the reasons behind it.
blog serve build every site, then serve them and the editor
blog build render every site into the publish directory
blog new-site ID create a site
blog invite --id ID one-time link that creates a user (the first one is admin)
blog invite --user ID one-time link that adds a passkey to an existing user
blog logout-all sign out every session
Requires Rust (stable) and just. nasm makes AVIF encoding
faster; bun is only needed for the browser tests and the editor bundle.
just dev-setup # once: checks tools, installs the git hooks
just dev # editor at http://write.localhost:8080, no sign-in
just dev creates a demo site on first run. Each site is served at
http://<site id>.localhost:8080; *.localhost needs no DNS setup. If 8080 is taken, the next
free port is used and printed. Set PORT=9000 to prefer another one.
In debug builds the editor's templates, CSS and JS are read from src/editor/ on every
request, and the site theme from theme/ on every build, so changes show up on refresh or on
the next save. Rust changes need a restart.
Other recipes:
just dev-auth # with passkey sign-in; create an account with `just invite <id>`
just dev-ts # the same over Tailscale HTTPS (passkeys need https off localhost)
just test # clippy and unit tests
just e2e # browser tests: editor and passkeys, via Chromium's virtual authenticator
just build # optimised binary in target/release/blog
just docker # build the container image
just docker-run # run it locally with a named volume
just editor # rebuild the committed CodeMirror bundle
just dev-ts needs sudo tailscale set --operator=$USER once. A passkey is tied to the host
it was made on, so localhost and Tailscale each need their own.
src/
main.rs CLI
serve.rs one listener: the editor on its host, each site on its domain
app.rs shared state: sites, rebuilds, background image processing
store.rs key-value storage (local disk now, S3/R2 later), conditional writes
site.rs sites and site.toml
posts.rs posts in the store: save, rename, revisions, trash
content.rs front matter and post parsing
markdown.rs comrak, MathML, highlighting, table of contents, <picture>
media.rs image processing: orientation, metadata, sizes, AVIF
build.rs renders a site into static files
feeds.rs RSS, sitemap, robots.txt
theme.rs the built-in theme, or a theme directory on disk
auth.rs users, passkeys, signed sessions, invites
editor/ the editor: handlers, templates/, static/
theme/ the default theme (compiled in): templates/ and static/
editor/ CodeMirror bundle source (built into src/editor/static/vendor/)
e2e/ browser tests
scripts/ migrate-from-go.py
docs/DESIGN.md design and roadmap
Everything lives under the store directory (BLOG_STORE, default ./data):
auth/users.json users, passkeys, roles
auth/secret signs session cookies (created on first start)
sites/<id>/site.toml title, domain, nav, editors, section intros
sites/<id>/content/<kind>/<slug>.md posts
sites/<id>/revisions/… every saved version
sites/<id>/trash/… deleted posts
sites/<id>/media/… uploads as served (processed images)
sites/<id>/originals/… uploads as received, private
Generated sites go to BLOG_PUBLISH (default ./data/public), one directory per site. They
can be deleted at any time; the next start rebuilds them.
Back up the store directory. It holds only plain files, so any file copy works.
+++
title = "Go needs immutable pointers"
date = 2025-04-25
updated = 2025-05-01 # optional
description = "…" # optional
tags = ["go"]
draft = true # not published while true
wip = true # shows a work-in-progress notice
toc = true # shows a table of contents
+++
Markdown, with $math$, fenced code and raw HTML.
A post at content/articles/foo.md is served at /articles/foo/; posts under pages/ at
/<slug>/. The editor writes these files for you; editing them by hand works too.
| Variable | Default | |
|---|---|---|
BLOG_STORE |
data |
store directory |
BLOG_PUBLISH |
data/public |
generated sites |
BLOG_THEME |
theme |
theme directory; the built-in theme is used if it doesn't exist |
BLOG_ADDR |
127.0.0.1:8080 |
listen address |
BLOG_EDITOR_ORIGIN |
http://write.localhost:<port> |
the editor's public URL; passkeys are bound to its host |
Each site's public domain is domain in its site.toml.
- Sign-in is passkey-only, with user verification. New users and new passkeys need a one-time
link (30 minutes):
blog inviteon the server, or the Users and Account pages. - Sessions are 30-day signed cookies (
__Host-, HttpOnly, Secure, SameSite=Lax). Nothing is stored per session;blog logout-allrevokes them all. - Admins can do everything; editors only touch sites whose
site.tomllists them ineditors. - State-changing requests must come from the editor's own origin.
- The editor has a strict Content Security Policy. Previews run in a sandboxed iframe with an opaque origin, so nothing in a post can act as the person viewing it.
- SVG and HTML uploads are refused; uploads are served with
CSP: sandboxandnosniff.
The Dockerfile builds a static musl binary (in rust:1-alpine) onto an empty scratch
image of about 20 MB. It runs /blog serve on port 8080 as uid/gid 65532.
docker build -t blog .
docker run -p 8080:8080 -v blog-data:/data \
-e BLOG_EDITOR_ORIGIN=https://write.example.com blog
docker run --rm -it -v blog-data:/data blog invite --id you
docker run --rm -it -v blog-data:/data blog new-site <id> # then set its domain in site.toml
- Volume
/dataholds the store; generated sites go in/data/public. - Set
BLOG_EDITOR_ORIGINto the editor's exact URL; passkeys depend on it. The image already setsBLOG_STORE,BLOG_PUBLISH,BLOG_ADDRandMIMALLOC_ALLOW_THP=0(which keeps idle memory around 1.6 MB on hosts with transparent huge pages always on). - Optional:
BLOG_THEMEplus a mount, to use a theme from disk without rebuilding. - Memory limit: at least 256 MB. Encoding a 12 MP photo to AVIF peaks around 190 MB, then drops back to a few MB.
- The image has no time zone data or CA certificates: times are UTC, and a future S3/R2 store will need certificates added.
docker stopexits cleanly: the server handles SIGTERM.
With Dokploy: create an application from this repo with the Dockerfile build type, mount a
volume at /data, and point both the site's domain and the editor's domain at container
port 8080 with HTTPS on. Set BLOG_EDITOR_ORIGIN to the editor's exact URL.
blog new-site structwafel --store /data
python3 scripts/migrate-from-go.py <old data dir> /data structwafel
This converts the posts in blog.db to Markdown files and copies the uploads. Media URLs
stay the same. Passkeys can't move (they're bound to the old origin), so create a new admin
with blog invite --id <you>, then set domain in sites/structwafel/site.toml.