May 5, 2026 · issue #2

One VPS, one binary, one database

If you remember nothing else from this site, remember this:

One VPS. One Go binary. One SQLite file. Continuous backup to object storage. Caddy for HTTPS. systemd to keep it alive. A shell script to deploy.

That is the whole stack. The rest of this issue is what each piece is doing, what it replaces, and what it costs at scale.

If that sentence is enough — go read the manifesto, install the skill, and get on with shipping. If you want to know why each piece is there, keep reading.

The picture

browser
  │
  ▼
┌──────────────────────────────────────────┐
│  one $5 VPS (Hetzner cx22, 4GB RAM)      │
│                                          │
│  Caddy ──► Go binary ──► SQLite (WAL)    │
│                             │            │
│                             ▼            │
│                         Litestream       │
└─────────────────────────────┬────────────┘
                              │
                              ▼
                        Cloudflare R2
                     (continuous backup)

That’s the architecture diagram. It fits in a tweet. It also runs the entire control plane for Borela in production: marketing site, magic-link auth, dashboard, locked v1 protocol, dev mailer.

What each piece is doing

The VPS

A $5/mo Hetzner cx22 (4 GB RAM, 2 vCPU, 40 GB disk, 20 TB transfer). Pick a region close to your users. SSH in. It’s a Linux box. You can read every file on it.

This is not a compromise. A single VPS is a forcing function. It tells you: if you can’t fit the app here, you owe yourself an honest answer about why, before you reach for K8s.

The Go binary

One file. CGO_ENABLED=0 go build -o app . produces a statically-linked Linux binary. scp it to the VPS. systemctl restart. Done. No interpreter, no runtime, no node_modules, no container layer.

The HTTP router is http.ServeMux from the standard library. Since Go 1.22 it supports method-prefixed routes (mux.HandleFunc("POST /v1/whatever", handler)). You no longer need a router library.

The database

SQLite, in WAL mode, in a single file at /home/<app>/data/app.db.

WAL mode means: readers do not block writers, writers do not block readers. You get real concurrent reads with one writer. For 99% of side projects, that ceiling is irrelevant — modern SQLite handles 10k+ writes/sec on an NVMe-backed VPS.

The big objection — “but what about backups?” — is the next piece.

Litestream

Litestream watches your SQLite WAL and streams it to object storage in near-real-time. If your VPS dies, you can restore the database to the last second on a fresh box with one command:

litestream restore -o /var/lib/app/app.db s3://bucket/app.db

We use Cloudflare R2 because it has zero egress fees. The bill is $0.12/mo at our scale. You can also use S3, B2, GCS — anything S3-compatible.

You do not have a backup until you have restored from it. Run a restore drill the day you set this up. Then run another one in a month. Future issues will go deep on this.

Caddy

Caddy is a web server that handles HTTPS automatically. You put your domain in the Caddyfile, Caddy talks to Let’s Encrypt, you have TLS forever. No certbot, no cron, no nginx config.

The full Caddyfile for a one-app box:

api.example.com {
    reverse_proxy 127.0.0.1:8080
}

That’s it. That’s the whole web server config.

systemd

systemd runs your binary. It restarts it if it crashes (Restart=on-failure). It captures stdout/stderr into the journal, where you read it with journalctl -u app -f. It’s already on every modern Linux box. It’s the runtime you didn’t have to install.

A hardened service file is about 22 lines:

[Service]
Type=simple
User=app
ExecStart=/usr/local/bin/app
Restart=on-failure
RestartSec=2

NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/home/app/data
PrivateTmp=yes

The full template is in the repo.

The deploy script

10 lines. Build, ship, install, restart, smoke-test:

#!/usr/bin/env bash
set -euo pipefail
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app .
scp app root@$VPS:/tmp/app.new
ssh root@$VPS '
  sudo install -m 755 /tmp/app.new /usr/local/bin/app
  sudo systemctl restart app
  sleep 1
  curl -fsS http://127.0.0.1:8080/healthz
'

That’s the whole deploy pipeline. It’s readable when tired. It works on every box that has SSH. It does not require a Docker registry, a CI vendor, or a YAML file.

What this replaces

You’d reach for Boring Stack picks Why
Postgres SQLite + WAL + Litestream One file. Concurrent reads. Continuous backup. Migration to Postgres later is pgloader + a connection string.
Vercel / Render / Fly Hetzner + Caddy $5/mo at every scale. Auto-HTTPS. No platform lock-in.
Prisma / GORM / SQLAlchemy stdlib + sqlc Type-safe Go generated from .sql files. No runtime layer. You can read your own queries.
Docker / docker-compose / K8s systemd + one binary Already on every Linux box. Restart=on-failure is one line.
GitHub Actions deploy pipeline 10-line shell script Readable when tired. No vendor. No YAML.
Datadog / New Relic journalctl + healthz + uptime check Free for the first year of any side project. Add observability when there’s something to observe.

What this costs

The exact bill for the Borela control plane this month:

hetzner cx22          $4.59
cloudflare r2         $0.12
boringstack.org dns   $1.25
resend (free tier)    $0.00
────────────────────────────
total                 $5.96/mo

That’s the whole control plane: marketing site, magic-link auth, dashboard, the locked v1 protocol that babysits real production apps, and the dev mailer. One box. One database. Six dollars.

Future issues will publish this number every month. When it changes, I’ll explain why.

When this is wrong

Boring Stack is not the right answer for every project. The skill ships a “When NOT to use this” section, and so does this site. The honest list:

If your project doesn’t match any of those, Boring Stack probably fits. The point isn’t to never use Postgres or never use Docker. The point is to make complexity a conscious choice instead of the default.

The deal

This is issue #2 of a weekly newsletter. The deal is one practical field note every Tuesday — what shipped, what broke, how the bill changed, which defaults held up, and what the latest restore drill found.

Next Tuesday: “SQLite is a database. Stop apologizing.” The full case for SQLite in production, with benchmarks, with the failure modes, with the migration path when you outgrow it.

Subscribe if you want it. Star the repo if you want to follow along quietly. Install the skill if you’re ready to try it on a project.

Reply with your stack. I read every reply.