Most of what’s on this site ships through a pipeline that looks unremarkable from the outside — push to GitHub, CI builds an image, it lands on a VPS. What’s underneath that is less usual: two independent k3s clusters, a self-hosted AI agent with its own scoped access to both, and a pile of repurposed hardware doing full-time duty as a build cluster. None of this needed to be this deliberate for a one-person project, but the agent doing a lot of the day-to-day operating work needed the same kind of guardrails a team of humans would insist on, so I ended up building them anyway.
This is the part that doesn’t fit on a project card: the actual hosts, how the two
clusters differ in what they’re trusted to do, and how an agent gets to touch
production without being able to reach kubectl delete on a Tuesday afternoon.
The Hosts
Five machines, three different jobs:
- hetzner (
ubuntu-4gb-hil-1) — a Hetzner Cloud VPS running the production k3s cluster: all nine live apps, the Woodpecker CI server, host-level Postgres and Redis, the Cloudflare tunnel, and the observability stack. - openclaw-gateway — a headless Ubuntu box (an old Core i7-4500U laptop, 7 GB of RAM, repurposed rather than bought) that runs nothing app-related. Its only job is the OpenClaw agent gateway — the thing that does a lot of the day-to-day operating.
- homelab — a second, independent k3s cluster, and the one that’s changed the
most: two Intel NUCs (
nuc-a,nuc-b) plus the 2017 MacBook Pro (16 GB RAM) that used to run this cluster alone via Rancher Desktop, wiped and rebuilt ashomelab-macrunning plain Ubuntu Server after that setup died for good. Three nodes, ~24 cores / ~47 GB combined. It exists to take CI load off the VPS — a Woodpecker build agent, a BuildKit daemon — and now also hosts Plane and the Grafana/VictoriaMetrics stack.homelab-maccarries the labelhomelab.link108/role=storageand is the one node with actual stateful data pinned to it, since it’s the newest and most reliable of the three.
None of this is cloud-scale hardware. The interesting part isn’t the specs — it’s how carefully the boundaries between them are drawn.
Tailscale tailnet (private mesh)
|
+----------------------+-----------------------+
| |
openclaw-gateway homelab (3-node k3s)
(headless Linux laptop) nuc-a · nuc-b · homelab-mac
OpenClaw agent + Slack Woodpecker build agent, BuildKit,
+ 7 MCP servers Plane, Grafana/VictoriaMetrics
|
| ssh, dispatcher-restricted commands only
v
hetzner (Hetzner VPS, k3s)
byah · deckforge · landlordlog · bodega-bartender
slopyard · game-theory-sim · reliquary-works
games · meandering-megan
Woodpecker server · host Postgres/Redis
Cloudflare tunnel · observability stack
Two Clusters, Two Trust Levels
hetzner and homelab are both k3s, but the agent’s access to each is intentionally
lopsided. On hetzner — the cluster actually running production traffic — the
ServiceAccount OpenClaw authenticates as gets a ClusterRole called openclaw-reader:
get/list/watch on pods, services, deployments, jobs, nodes, events, and pod logs, plus
metrics.k8s.io for kubectl top. No secrets. No write verbs at all. If the agent
needs to actually change something on hetzner, it has to go through a different door
entirely (below).
On homelab, the same ClusterRole name carries real write permissions:
create/update/patch/delete on deployments, statefulsets, services, configmaps, PVCs,
jobs, and ingresses — but still explicitly no Secret access, no Namespace
create/delete, no cluster-admin. That’s not an oversight; the RBAC manifest says so
directly in its own comments. homelab is a CI/build cluster with nothing
customer-facing on it (Plane included — it’s internal, tailnet-only), so letting the
agent actually deploy things there is a reasonable blast radius. hetzner is not, so it
doesn’t get that. One thing the rebuild fixed along the way: the kubeconfig now points
at nuc-b’s real Tailscale-issued cert instead of the old Mac cluster’s
self-signed one, so this is the one connection that no longer needs
insecure-skip-tls-verify.
Getting Code Out the Door
The actual shipping path — GitHub Actions building and pushing an image, opening a
deploy PR against homelab, Woodpecker auto-merging and applying it — is laid out on
the architecture page. It’s mostly boring, which is the point, but
the mesh it runs on isn’t always forgiving: a CoreDNS rule scoped to IN A only
recently broke every deploy silently, because kubectl’s dual-stack resolver also
queries AAAA, and an unmatched AAAA query fell through to a plugin that answered
NXDOMAIN instead of NODATA — a hard failure on a record type nothing was even using.
That’s the kind of bug that only shows up once you’ve built enough infrastructure to
have DNS plugins with fallthrough semantics in the first place.
The Agent Layer
OpenClaw is the thing actually running on openclaw-gateway: three routed models
behind one Slack-facing identity. The default agent (deepseek-v4-flash) triages
everything — status checks, quick kubectl calls, memory recall — and delegates out
for anything heavier: deepseek-v4-pro for multi-step reasoning and debugging,
kimi-k2 for anything over roughly 15k tokens or a full repo dropped into chat.
Routing is just an exec call the orchestrator makes to itself, not a separate
service.
It’s wired into seven MCP servers — Grafana, both Kubernetes clusters, Woodpecker, GitHub, Notion, and Plane — the same ones, as it happens, that the Claude Code session writing this page has access to. Same backends, same scopes, two different front doors.
Letting an Agent Touch Production
hetzner doesn’t hand out a kubeconfig with write access at all. Instead there’s a
single SSH key, installed with a command= restriction in authorized_keys that
routes everything through a dispatcher script — no interactive shell, ever.
SSH_ORIGINAL_COMMAND gets parsed against an explicit case statement with seven
named operations: cluster-status, pod-logs, disk-usage, woodpecker-health,
victoria-health, db-ro, and rollout-restart. Anything else is denied and logged.
Two of those are worth calling out. rollout-restart checks the namespace/deployment
pair against a hardcoded allowlist — the seven production apps plus the three
Woodpecker components — so the agent can restart deckforge but not coredns.
db-ro enforces read-only twice over: a regex blocklist rejects anything that looks
like INSERT/UPDATE/DELETE/DROP/ALTER/GRANT/COPY before it ever reaches Postgres, and
the connection itself authenticates as openclaw_ro, a role with no write grants at
all. Belt and suspenders — either check alone would probably be enough, but the point
of giving an agent SSH access is not having to trust that one clever prompt won’t
find the gap in the other.
OpenClaw agent --ssh hetzner "<cmd> <args>"--> dispatcher
(command= restricted,
no shell, ever)
|
+----------------------+----------------------------+
| |
read-only ops rollout-restart
cluster-status, pod-logs, hardcoded allowlist: the
disk-usage, woodpecker-health, 7 production apps + the
victoria-health 3 Woodpecker components
|
v
db-ro <database> <sql>
regex blocklist + read-only
Postgres role (openclaw_ro)
Networking as the Trust Boundary
Tailscale is what actually makes this workable. Every private surface — the
Woodpecker UI, both clusters’ APIs, Grafana — sits only on the tailnet, reachable by
device identity rather than by anything resembling a public port. The only thing
Cloudflare’s tunnel exposes is ci.byah.org, and even that’s narrowed at the nginx
layer to /hook and /authorize; everything else 404s before it reaches Woodpecker.
Internal-only hostnames get the same treatment through Tailscale’s Split DNS:
grafana.byah.org and plane.byah.org resolve only for tailnet devices, answered by
a dnsmasq instance running directly on nuc-b rather than inside the cluster — any
homelab node’s IP would work equally, since Traefik’s ingress runs on all three via
k3s’s built-in ServiceLB, but nuc-b is a stable-enough anchor. (This replaced the
old openclaw-node/Mac version of the same idea, which existed to work around a
Rancher Desktop host-networking bug that doesn’t exist on bare-metal k3s.) Neither
hostname has a public DNS record, and it’s not because someone remembered to lock it
down. There’s just nothing there to find.
What’s Still Moving
The five-host picture above replaced an earlier one. openclaw-node used to be this
same 2017 MacBook Pro running solo as its own k3s cluster via Rancher Desktop —
including, for a while, the OpenClaw Gateway process itself, running as a LaunchAgent
on the same box that was supposed to be a disposable build node, pending a cutover to
openclaw-gateway once that machine was confirmed healthy. That cluster died for good.
The fix wasn’t nursing it back to health; it was wiping the Mac, installing Ubuntu
Server, buying two Intel NUCs, and standing up homelab as an actual three-node
cluster with homelab-mac as just one member of it. The gateway cutover happened
along the way, by necessity — there’s no macOS left on that machine to run a
LaunchAgent on.
Not every trade-off from that rebuild is resolved, just made deliberately. Postgres
and Redis on homelab run as single-instance StatefulSets pinned to homelab-mac
rather than anything HA (CloudNativePG, Redis Sentinel) — if that node goes down,
both go down with it until it’s back. That’s written into the repo’s own decision
record, not left implicit, with the explicit call to revisit only if node-reboot
downtime actually becomes painful.
None of that is a problem to hide. A homelab that’s honest about which parts are load-bearing and which parts are still being poured is more useful than one that pretends to be finished.