VayuPress Architecture
Last reviewed: 2026-10-16, against VayuPress 3.18.7
Purpose
VayuPress is one Go binary and one SQLite database that together serve a website, a blog, a mail server, encrypted chat, Tor onion services, an API, an MCP server and the console that runs them. It is built for a single modest VPS — 8 GB RAM, 4 vCPU, 50 GB NVMe — and is proven at 234,727 posts and fourteen sites on one Contabo VPS 10 (johal.in, October 2026).
Every decision behind this shape is recorded in the ADR registry.
System context
graph TD
Reader -->|HTTPS 443| Nginx
Nginx -->|127.0.0.1:8080| VP[VayuPress, one process]
MailPeer[Mail servers and clients] -->|25 587 143 993 110 995| VP
MCP[Claude and MCP clients] -->|HTTPS /mcp| Nginx
VP --> DB[(SQLite, WAL)]
VP --> Disk[/var/lib/vayupress: media, mailboxes, PGP store/]
VP --> Cache[/var/cache/vayupress: rendered pages/]
VP -->|child process| Tor[tor, when VayuTor is on]
VP -->|subprocess, seccomp| Plugin[plugins, when enabled]
Nginx terminates TLS and proxies every page to the application; it serves
nothing from the page cache itself (the only files it serves directly are ACME
challenges). The application listens on loopback. The mail server listens on
the public mail ports directly — systemd grants the non-root service
CAP_NET_BIND_SERVICE for that and nothing else.
A request, in order
Every HTTP request passes the same chain (cmd/vayupress/routes.go):
- Core — a request ID; the client address, honouring forwarding headers
only from a configured proxy (
realIPMiddleware); a structured log line; security headers (CSP with a per-request nonce, HSTS,X-Frame-Options); compression for clients that ask. - Maintenance — the maintenance page for visitors when the operator has taken the site down; the console, health and the signed-in operator pass.
- Crawler block — when the operator has hidden the site from indexers.
- Tor onion — maps an
.onionhost to the domain it serves, and addsOnion-Locationon clearnet responses. - Domain — resolves which hosted site owns the
Host(VayuDomains), then that site's CSP. - Redirects — operator-defined 301/302s.
- Aegis L0, the sovereignty lane — caps public concurrency so the console keeps headroom under load, before any classification or database work.
- VayuShield — classifies the request: fair-shed, reputation, inspection
rules, challenges, the operator's request rules (
docs/VAYUSHIELD.md). - Release mirror, CORS, then the router.
Pages
Public pages — home, posts, topics, for every domain — are rendered from the
database and kept on disk (internal/render).
- Kept and invalidated. A page is fresh until a write purges it or a
renderer or settings change advances the staleness cutoff
(
render.CacheEntryFresh). Edits purge their own pages; a theme change stales them all. - Never waiting on a re-render. A stale page is served at once and redrawn
off the request path, single-flighted and bounded (
cache_swr.go). The operator who made it stale is the exception, and sees the new page. - Cold renders are capped. Pages not on disk take a render slot
(
admitColdRender); past the cap a visitor is asked to retry rather than queue the server into the ground. - Listings read cards, not bodies. A listing chooses its posts from a
covering index, then reads each post's card (title, excerpt, picture) from
article_cards. The warmer builds missing cards ahead of visitors, so a cold topic page reads two hundred small rows instead of two hundred post bodies. - The warmer. Every five minutes it builds missing cards, then rebuilds
missing or stale pages newest first, at the pace the host can spare
(
cachewarm.go,internal/pace). - Browsers revalidate. Pages are sent
Cache-Control: no-cachewithLast-Modified; an unchanged page answers304. - One switch. Settings › Advanced › Page cache turns keeping pages off; feeds,
sitemaps and
robots.txtare kept regardless.
The theme decides how a page is drawn. Halcyon is the default
(internal/render/halcyon*, ADR-0166): its own layouts for home, posts, topics
and search, one stylesheet (9.4 KB gzipped) and one script (3.8 KB), fonts
served from the origin. Every post carries one structured-data graph built by
the server, whatever the theme.
Writes
graph LR
API[Editor / API / MCP] --> Jobs[(write_jobs)]
Jobs --> W[Workers, WORKER_COUNT, default 3]
W --> Articles[(articles, article_tags)]
W --> Events[Event bus]
Events --> Search[VayuFind index]
Events --> Purge[Page purge]
Events --> Outbox[(outbox) → relay]
- A write is persisted as a job in SQLite before any worker takes it
(
internal/queue), so a crash resumes it. - A failed job is retried three times with backoff (10 s, 20 s, 40 s, capped at five minutes) and then moved to the dead letter, where the console's replay explorer can run it again.
- Each write publishes a domain event (
internal/events). Subscribers keep the search index current, purge the post's pages and announce it to IndexNow (registerEventHandlers); events that must survive a crash are committed with the write itself and delivered by the outbox relay (internal/outbox). A periodic reconciler heals any search drift. - Search is VayuFind, built in (
internal/search, ADR-0101): no external service, maintained by those events, its index snapshotted to disk so a restart restores rather than rebuilds.
Telemetry
Page views, collected events, reading engagement and the shield's events are
never written on the request. Each is counted into a bounded queue in memory
and written in batches by one writer (internal/dbbatch, and the view
recorder in internal/analytics), so a flood costs memory up to a cap, never
the write connection.
- Full: a new event is dropped and counted.
- Refused by the database: the batch is kept and retried, and counted as
lost after
dbbatch.MaxAttempts(3) refusals in a row. - Read-only mode (
mode.Mode.HoldsTelemetry): the queues hold, and write when the mode returns. - Shutdown:
flushTelemetrywrites what they hold after the shutdown channel closes and before the database does.
System › Monitoring › Telemetry shows each queue's count, cap, drops and
losses (cmd/vayupress/telemetry_queues.go).
The database
One SQLite file in WAL mode (internal/db).
- One writer. The write connection is limited to one; readers use a separate pool, and the console's heavy reads a pool of their own.
- Migrations are numbered SQL files compiled into the binary; each applied migration's SHA-256 is recorded, and the server will not start if one differs (ADR-0034).
- Covering indexes serve the hot listings:
idx_articles_feedfor home,idx_article_tags_livefor topics. - Append-only audit log, enforced by triggers.
Media
Uploads are stored content-addressed under /var/lib/vayupress/media, checked
by magic number, and downscaled with the standard library's codecs only
(internal/imageproc — no libvips, no CGO). An image that declares dimensions
beyond the pixel budget is refused before it is decoded.
Control of the install itself
- Modes (
internal/mode): normal, degraded, read-only, recovery, maintenance, quarantined — shown on every console page, journalled, and restored after a restart. What each refuses is in architecture/system-modes.md. - Faults (
internal/fault) escalate to a mode when they repeat: WAL write faults and migration faults to read-only, plugin faults to quarantined, outbox failures to degraded. - The pacer (
internal/pace) is asked before each step of background work — backups, the warmer, card building, restore drills — and sizes it by how busy the host is.
The parts, by package
| Area | Packages | ADR |
|---|---|---|
| Rendering, themes, Halcyon | internal/render, internal/theme, internal/blockrender, internal/markdown, internal/diagram |
ADR-0086, ADR-0166 |
| Search | internal/search (VayuFind) |
ADR-0101 |
| Bot protection | internal/vayushield, internal/shieldaudit |
ADR-0111 |
| Analytics | internal/vayuanalytics |
ADR-0097 |
| Mail, PGP, chat | internal/vayuos/mail, internal/vayuos/pgp, internal/vayuos/vayutalk |
ADR-0077, ADR-0131, ADR-0144 |
| Tor | internal/vayuos/vayutor, internal/vayuos/torspace |
ADR-0138, ADR-0141, ADR-0143 |
| Sites | internal/domain, internal/bizsite, internal/customsite, internal/sitedoc, internal/docsite |
ADR-0152–0154, ADR-0161 |
| API, keys, MCP, OAuth | internal/api, internal/apikeys, internal/mcp, internal/oauth, internal/vcb |
ADR-0134, ADR-0135, ADR-0139 |
| Members, payments | internal/members, internal/payments, internal/newsletter |
ADR-0087 |
| Automation, observation | internal/vayuflow, internal/vayuveil |
ADR-0150, ADR-0151 |
| Backups, updates | internal/backup, internal/vayukeep, internal/update, internal/sqlitecopy |
ADR-0145 |
| Plugins | internal/plugins, internal/sandbox |
ADR-0057 |
| Kernel | internal/db, internal/queue, internal/outbox, internal/events, internal/mode, internal/fault, internal/pace, internal/metrics, internal/logging |
ADR-0033–0035 |
| Console | cmd/vayupress (admin_os_*.go), internal/ui |
ADR-0068 |
Which package may import which is a test, not a convention
(internal/archcheck).
Security architecture
graph TD
Internet --> UFW[ufw: 22, 80, 443, mail ports]
UFW --> Nginx[nginx: TLS]
Nginx --> Shield[Aegis L0 + VayuShield]
Shield --> Auth[Session + TOTP / scoped API key / OAuth]
Auth --> CSRF[CSRF double-submit on writes]
CSRF --> Role[Role per path: osPathMinLevel]
Role --> Handlers
Handlers --> DB[SQLite]
Entry points, assets and every mitigation's mechanism are in the threat model and attack surfaces.
Budgets that are checked
| What | Budget | Where it is enforced |
|---|---|---|
| Binary size | 45 MB gzipped (60 MB as shipped at 3.18.7) | make check-size |
| Halcyon stylesheet, script | 9.4 KB, 3.8 KB gzipped (recorded at 3.18.0) | Halcyon's byte budget tests |
| Lighthouse | Home, a post and four console pages | lighthouse.yml |
| Console design | Every value on the token scale | The design lint in the browser tests |
| Reproducibility | Two cold builds byte-identical | scripts/reproducible-build.sh |
Direct dependencies
| Module | Role |
|---|---|
github.com/go-chi/chi/v5 |
HTTP router |
github.com/mattn/go-sqlite3 |
SQLite (linked statically, CGO) |
github.com/microcosm-cc/bluemonday |
HTML sanitising (posts, comments, SVG diagrams) |
github.com/yuin/goldmark |
Markdown |
github.com/alecthomas/chroma/v2 |
Code highlighting |
github.com/ProtonMail/go-crypto |
OpenPGP |
github.com/emersion/go-msgauth, blitiri.com.ar/go/spf |
DKIM, DMARC, SPF |
github.com/emersion/go-webdav, go-ical, go-vcard |
Contacts and calendars over DAV |
github.com/digitorus/pkcs7, software.sslmate.com/src/go-pkcs12 |
S/MIME |
github.com/sigstore/sigstore-go |
Verifying signed releases |
gitlab.torproject.org/…/goptlib, …/lyrebird |
Tor pluggable transports (obfs4) |
github.com/phuslu/iploc |
The embedded country table |
github.com/graphql-go/graphql |
The GraphQL API |
github.com/parquet-go/parquet-go |
Analytics export |
github.com/rs/cors |
CORS |
rsc.io/qr |
QR codes for TOTP enrolment and mail setup |
golang.org/x/crypto, x/net, x/sys, x/text |
Go's extended standard library |
Vendored browser assets, served from the origin and embedded in the binary
(//go:embed static, mirrored to STATIC_DIR on boot, ADR-0099): htmx (0BSD)
and Alpine.js's CSP build for the console, DOMPurify for previews, and Pico CSS
for the classic public theme. Licences are in NOTICE.
No frontend framework and no build step: the console is server-rendered Go with htmx (ADR-0107).