< back to work > case_study

> · project

Sovereign Stack

One small Hetzner box, every side project — PocketBase × N behind Caddy, with change-aware CI/CD, encrypted backups, and disaster recovery as a first-class feature

The infrastructure-as-code repo behind plocic.dev: a single EU VM running every PocketBase instance behind Caddy — plus self-hosted analytics and real-user web vitals — with auto-deploys that know what changed, nightly encrypted backups, and a stack you can resurrect on a blank server from a git clone and a passphrase.

Stack

  • Docker Compose
  • Caddy
  • PocketBase
  • SQLite
  • Meilisearch
  • Umami
  • PostgreSQL
  • imgproxy
  • BorgBackup
  • Hetzner Cloud
  • GitHub Actions
  • Verdaccio
  • systemd
  • Node.js

Engagement

  • statusmaintained
  • modelside project
  • since2026
  • > solo — one VM, every side project

Links

  • > no public links

plocic-infra is the single source of truth for everything that runs behind plocic.dev. One small Hetzner VM, one docker compose up, and every side project’s backend lives there: Przepisnik, Sumo, a crash-report ingest, self-hosted analytics and real-user web vitals, search, image resizing, a mailer, a private npm registry, and monitoring — all behind one Caddy reverse proxy with automatic TLS. The box’s /opt/app is a checkout of this repo; push to main and it deploys itself.

It exists because the alternative was a stack of per-project SaaS bills — a managed Postgres here, a hosted PocketBase there, a transactional-email plan, a search-as-a-service tier — each one small, all of them together a monthly tax on the privilege of having shipped something. This replaces the lot with a single predictable VM and a repo I own end to end.

Every new project used to mean another invoice. Now it means another twelve-line block in docker-compose.yml.

~/plocic-infra

$ tplocic stats

  • app + shared services 14 containers, 1 VM
  • cost to add a project ~0 (Go binary + SQLite)
  • public surface 1 — only Caddy is exposed

The shape of it

Each PocketBase is a single Go binary against SQLite, so the marginal cost of another project is essentially zero. They share the expensive parts — TLS, search, image processing, mail, monitoring — and Caddy fans the subdomains out to internal containers that never touch the public internet.

Hetzner VM (CX23 · Ubuntu 26.04 · Helsinki)        Hetzner Storage Box

├─ Caddy :80/:443/:443udp   ── auto Let's Encrypt, HTTP/3
│   ├─ pocket.przepisnik.app ─→ przepisnik (PocketBase)
│   ├─ pocket.sumo.plocic.dev ─→ sumo       (PocketBase)
│   ├─ report.plocic.dev      ─→ errors     (PocketBase, opt-in reports)
│   ├─ vitals.plocic.dev      ─→ analytics  (PocketBase, web-vitals ingest)
│   ├─ stats.plocic.dev       ─→ umami      (cookieless traffic · Postgres)
│   ├─ view.*.plocic.dev      ─→ read-only vitals + report dashboards
│   ├─ search.przepisnik.app  ─→ meilisearch
│   ├─ img.przepisnik.app     ─→ imgproxy
│   ├─ mail.plocic.dev        ─→ mailer
│   ├─ npm.plocic.dev         ─→ verdaccio
│   └─ monitor.plocic.dev     ─→ beszel + dozzle

└─ nightly · sqlite .backup + secrets ─→ borg (encrypted, deduped) ─→ ┘

Everything but Caddy lives on the internal docker network. The firewall opens exactly three ports — SSH from my IP, 80/443 from everywhere — and denies the rest. PocketBase’s 8090 is never published; the only way in is through the proxy.

Deploys that know what changed

The part I’m proudest of is the GitHub Action. It doesn’t blindly docker compose up on every push — it diffs the repo against the host by content hash (CI rewrites mtimes on every checkout, so timestamps lie) and decides the smallest correct action:

  • changed docker-compose.yml / a Dockerfile → rebuild and recreate
  • changed the Caddyfile only → live caddy reload, no container restart
  • changed the report ingest → restart just that one container

Before anything touches the host, a validation gate runs offline: docker compose config, caddy validate, shellcheck, and a completeness check that fails the build if an env var is used in compose but undocumented in .env.example. Then it snapshots the current config and image IDs, syncs, health-checks eight public endpoints over real DNS+TLS, and auto-rolls-back — config and image tags only, never data — if any of them returns a 5xx.

Backups that assume the worst

The data layer is SQLite, which is mid-write at any given moment, so the nightly job doesn’t just rsync pb_data. It takes a consistent online copy of each SQLite database with .backup, pg_dumps the one Postgres in the stack (Umami’s), bundles them with uploaded files and every secret needed to come back from nothing, and pushes the lot to a Hetzner Storage Box via Borg — encrypted, compressed, deduplicated — on a systemd timer with jitter so it never lands on a round minute. Retention is 7 daily / 4 weekly / 6 monthly.

Derived data — the Meilisearch index, the monitoring history — is deliberately not backed up. It’s computable, so it’s rebuilt from source after a restore by a bootstrap script. Backups stay small and honest about what’s actually source-of-truth.

Disaster recovery is a feature, not a hope

The repo is explicit that two things, together, can resurrect the entire stack on a blank server: the git repo (all code, compose, routing, Dockerfiles) and the Borg archive + passphrase (all data and secrets). Neither alone is enough — the git repo can’t conjure your data, the Borg archive can’t run without the compose — and that separation is the point. The README walks the full path: provision a box, git clone to /opt/app, borg extract, drop the secrets back, docker compose up --build, rebuild the search index. Time-to-resurrection is measured in minutes, and it’s been written down before it was ever needed.

Owning the analytics, too

The last third-party dependency on the public site was the way it measured itself — @vercel/analytics for traffic, @vercel/speed-insights for Core Web Vitals. Both are gone now, replaced by two services on this box, because “stop renting your backend” rang hollow while a vendor still watched every visitor. Traffic runs through Umami — cookieless, MIT, no cross-site tracking — at stats.plocic.dev, backed by the single Postgres in the whole stack. Real-user vitals — LCP, CLS, INP, FCP, TTFB — land in an infra-owned PocketBase at vitals.plocic.dev, the same trick as the error ingest: the schema, hooks, and hardening migrations are committed here and mounted read-only, so vanilla PocketBase plus this repo is the service.

The collector is a ~2 KB deferred keepalive beacon that fires once, when the page is hidden — late enough that it can’t move a Lighthouse lab score. That’s deliberate: measuring real users shouldn’t tax the synthetic number, and it definitely shouldn’t tax the real one. Both services ship inert behind a docker-compose profile — a normal deploy doesn’t even start them — and Umami’s Postgres folds into the nightly Borg run.

On top of both sits a pair of read-only dashboards, view.vitals and view.report, built in the site’s own design language instead of a vendor’s console: p75 rollups with a plain-language explainer of what the percentile means, interactive chart tooltips, and Lighthouse-style good / needs-improvement / poor rating shapes.

The shared services, briefly

  • mailer — a 162-line, zero-dependency Node service that renders branded transactional email per project and dispatches over an HTTPS API (Hetzner blocks outbound SMTP). One mailer, many brands, per-key rate limits.
  • report — an opt-in crash/error ingest on its own PocketBase. The API key ships in the frontend and is not the security boundary; a strict server-side schema gate (field allowlist, type checks, length caps) and per-IP rate limiting are. A leaked key can’t store junk.
  • verdaccio — a private npm registry at npm.plocic.dev so @plocic/* packages (like the @plocic/report client) live on my own box, with public deps cached from npmjs.
  • imgproxy + meilisearch — on-the-fly AVIF/WebP resizing and typo-tolerant search, shared across whichever projects want them.