Platform Project Topology — lans-h.cc fleet

A reusable platform layer (MVP) — four workloads today, additive onboarding for the next

A ground-up re-imagining of platform infrastructure — how I'd design one, drawing on 10+ years of engineering. It owns everything two or more apps stand on; each app declares only its own thin slice — a namespace, a Host(...) rule, a database role, and one deploy hook.

prod: OCI · Oracle Linux 9 · arm64 · Kubernetes + Traefik

Internet Cloudflare DNS lans-h.cc · *.lans-h.cc  (reg: Spaceship) PROD · Kubernetes (OCI) Oracle Linux 9 · arm64 · Traefik ingress HTTPS · 80/443 signed deploy webhook Traefik  :80 / :443 wildcard *.lans-h.cc = default cert · apps route by Host(...) · TLS handled centrally Deploy webhook HMAC-signed delivery ns: web my_website Astro static site · lans-h-site Host: lans-h.cc (+www 301) img: localhost/lans-h-site LIVE ↗ ns: gelp gelp Next.js 15 · Drizzle · Leaflet Host: gelp.lans-h.cc img: localhost/gelp LIVE ↗ ns: transigen transigen Next.js 15 · React 19 · SSE Host: transigen.lans-h.cc img: localhost/transigen LIVE ↗ ns: snoopy snoopy discord.py · Gemini · Postgres in-app only img: localhost/snoopy-home:<sha> in-app ns: data Shared Postgres per-app <app>_rw least-privilege roles · db-per-app PUBLIC connect revoked · reachable only in-cluster ns: platform Cluster plumbing cert-manager + Cloudflare DNS-01 issuer *.lans-h.cc wildcard cert · TLSStore www → apex 301 redirect Image build model — build on-node podman build on a node → import into the cluster's containerd → localhost/<app> names + Never/IfNotPresent, so a registry pull is never attempted Deploy triggers (per-app, by choice) snoopy   push v* tag → GitHub Actions (pytest) → SSH into a node → build + rollout   release-gate: v* tag gelp      push main → Actions (next build) → signed deploy webhook   test-gate: build must pass (route A) transigen push main → Actions (next build) → signed deploy webhook   test-gate: build must pass (route A) my_website push main → GitHub webhook → deploy   static site · ships on push promote → prod STAGING · local Kubernetes (laptop) mirrors prod — same manifests, dev image builds Same topology as prod identical namespaces + Ingress rules dev image builds, imported locally promoted to prod once green Shared Postgres (dev) one local Postgres in the cluster isolated dev database per app same per-app least-privilege roles Future: GitOps lab Argo CD — pull & reconcile self-heal / anti-drift replaces push-to-deploy Local analysis worker runs off-cluster (host process) polls a jobs table via an outbound tunnel poll via tunnel

What this repo owns

A single platform repo is the source of truth for shared infrastructure: the nodes, Kubernetes, Traefik, the wildcard cert, the shared Postgres, and the deploy webhook listener. App repos own their own build + deploy — only what two or more apps stand on lives here.

Onboard a new app — 4 steps

  1. a namespace
  2. a Host(...) ingress rule (wildcard TLS covers it)
  3. a least-privilege database role (additive)
  4. one deploy hook

Every step is additive; the next app costs a fixed, near-zero amount.

Two orthogonal gate axes

AxisMeaningApplies to
test-gatebuild/tests must pass before deploycode services
release-gateonly a tagged version shipsopt-in

Trigger and gate are independent axes — each service mixes them to fit its own release cadence.

TLS & security posture

  • A single wildcard is Traefik's default cert, so an app needs only a Host(...) rule to get valid HTTPS.
  • Deploy webhooks are authenticated with an HMAC shared secret.
  • Postgres is reachable only inside the cluster; each app holds a least-privilege role scoped to its own database.

Live endpoints

AppURL
my_websitelans-h.ccopen ↗
gelpgelp.lans-h.ccopen ↗
transigentransigen.lans-h.ccopen ↗
snoopyDiscord botin-app

Why it's built this way — design trade-offs

One shared cluster

One shared cluster removes per-app cost and ops and forces clean multi-tenancy — namespaces plus a per-app DB role. The tenancy contract scales to more nodes unchanged; the trade-off taken is a single failure domain.

Platform repo = source of truth

Shared infra used to squat in whichever app repo needed it first → duplication and unclear ownership. Centralizing it adds one coordination point but draws a hard platform/app boundary: apps stay thin and onboarding is self-service.

On-node image builds

Build on a node and import into the cluster's containerd (imagePullPolicy: Never, localhost/<app>) — the exact local image is guaranteed to run, and the whole build pipeline is just the cluster itself. A registry can slot in later with the app contract unchanged.

Image stores get their own failure domain

Building on-node makes the image stores the fastest-growing thing on disk, and they used to share a filesystem with the OS, the cluster state and the database — one runaway build could take the cluster down with the disk. Each store now sits on its own volume, so what fills up is that volume and nothing else. A scheduled prune bounds them, with retention split by what each store is for: the runtime store keeps a rollback target, the build store keeps only enough to serve as layer cache for the next build.

Wildcard = the default cert

One wildcard is the default cert, so apps declare zero TLS config and every new subdomain gets valid TLS automatically — TLS is a platform concern, not an app concern. The trade-off taken is a single cert with a wider blast radius.

Shared Postgres, least-privilege per app

A per-app role scoped to its own database gives real tenant isolation on one shared server — a login-only, least-privilege role that keeps each app confined to its own data. Provisioning is additive and idempotent, and enforces the isolation inline as it runs (per-app REVOKE CONNECT / targeted GRANT) — it never touches another app's role.

Trigger and gate, decoupled

Each service keeps the trigger that fits it while sharing one signed deploy path. Trigger (how a deploy starts) is separate from gate (test-gate = build must pass; release-gate = tag only) — two independent axes mixed per service.