Deployment: Hetzner VPS + Cloudflare over AWS Amplify
Context
The engine builds fully static HTML into dist/<domain>/ via bun run build. In production, the Hono server serves static files using Host-header routing — one process, all domains.
We evaluated AWS Amplify Hosting. Key findings:
- Amplify supports multiple custom domains per app, but all domains resolve to the same
baseDirectory. Since the build outputs separate directory trees per domain (dist/kda.zone/,dist/wardleymaps.com/, etc.), one Amplify app per domain is required. - Amplify's managed CloudFront distribution cannot be customized (no CloudFront Functions or Lambda@Edge access).
- Amplify SSR supports Next.js middleware for Host-based routing, but adopting Next.js violates the no-framework and no-bundler principles.
- Three Amplify apps means sunset redirects, domain config, and SSL live outside the codebase — breaking
sites.yamlas single source of truth.
Current active domains:
| Domain | Type |
|---|---|
kda.zone |
personal (apex + www) |
wardleymaps.com |
project (apex + www) |
lego-submarine.kda.zone |
project (subdomain of kda.zone) |
Decision
Deploy to a Hetzner VPS with Cloudflare (free tier) as the TLS/CDN proxy layer.
Architecture
Cloudflare (HTTPS, CDN, DDoS) → Hetzner VPS (HTTP :80) → Hono server → dist/
- Cloudflare terminates TLS, caches static assets, provides CDN edge.
- Hono server listens on port 80, routes by Host header, serves from
dist/. - DNS managed via Cloudflare, synced from
sites.yamlby a script. - Auto-deploy via GitHub webhook: push to
main→ server pulls and rebuilds.
Why not Amplify
| Concern | Amplify (3 apps) | VPS + Cloudflare |
|---|---|---|
| Architecture match | Split across 3 apps | One process, all domains |
sites.yaml drives everything |
No — domains in AWS console | Yes — including DNS sync |
| Sunset redirects | Manual Amplify config | Engine handles it |
| Adding a domain | New Amplify app + console | Edit sites.yaml, run sync |
| Server ops | Zero | ~15 min/month |
| Cost | $0-1/month | ~$6/month |
The $4/month premium buys architectural coherence and operational simplicity for the common path (content and domain changes).
Setup
Server (one-time):
- Hetzner CX22 VPS, Ubuntu
- SSH on port 2222, key-only, fail2ban
- UFW: allow 2222/tcp, 80/tcp, deny everything else
- Bun installed, app cloned to
/home/deploy/app bun run build→dist/populated- Hono server as systemd service on port 80
- GitHub webhook listener for auto-deploy on push
Cloudflare (one-time per root domain):
- Add domain to Cloudflare, update registrar nameservers
- SSL mode: Full (not Strict — origin is HTTP)
- Always Use HTTPS: on
- A records for each active domain → server IP, proxied (orange cloud)
DNS sync script:
scripts/sync-dns.jsreadssites.yaml, creates/updates Cloudflare A records via API- Requires
CF_API_TOKEN, zone IDs, andSERVER_IPas env vars - Idempotent — safe to re-run
Cost
| Item | Monthly |
|---|---|
| Hetzner CX22 | ~$4 |
| Cloudflare (free tier) | $0 |
| Domain registration | ~$2 amortized |
| Total | ~$6/month |
Consequences
- Server maintenance is our responsibility. OS updates (~monthly), monitoring via UptimeRobot (free). Static sites on a VPS rarely have issues.
- No auto-scaling. Irrelevant for personal/project sites with low traffic. Cloudflare's CDN cache handles traffic spikes.
sites.yamlremains the single source of truth for domains, content, features, and now DNS. Adding or sunsetting a domain is a code change, not a console change.- Engine architecture preserved. One process, many domains, Host-header routing — exactly as PRINCIPLES.md intends.
- Cloudflare free tier is generous but is a dependency. If needed, can swap to Caddy (auto-TLS via Let's Encrypt) with zero code changes — just remove the proxy layer.