Skip to main contentUkasyah Nata Dewastoro
On-prem

Self-hosted Kubernetes for local development, shareable on demand

A three-node kubeadm cluster on arm64 that runs my local development work on a VMware host-only network, with real names and TLS. Nothing outside my machine can reach or discover it. Egress goes through a separate NAT interface, and any service can be shared publicly on demand through a Cloudflare tunnel that dials out over that NAT. Every component ships as plain, digest-pinned manifests with a functional verification gate.

0 inbound ports
Role
Sole engineer
Scale
3 nodes, 1 control plane, arm64
Stack
kubeadm 1.36, CRI-O, Calico
Targets
Host-only by default, public on demand, no inbound path

Context

This is a three-node kubeadm cluster on arm64 virtual machines: one control plane and two workers, running CRI-O and Calico. Its job is to host my local development work, reachable from my machine by name with valid TLS, the same way it would run in production.

Each VM has two network interfaces. A VMware host-only network carries all cluster and client traffic, and only my host can reach it, so nothing else on any network can discover the cluster. A NAT interface provides egress only: image pulls, certificate issuance and the tunnel all dial out through it, and nothing can come in.

When I want someone else to see something, I share that one service through a Cloudflare tunnel. Everything else stays host-only. The first service shared this way is a documentation site that describes the cluster it runs on.

Constraints:

  • Host-only by default; public access is opt-in per service
  • No inbound path at all: the NAT interface is egress-only
  • Small arm64 VMs: every image must be multi-arch and verified
  • A single person operates everything
  • Everything reviewable as plain manifests in git

Architecture and failover

Nodes sit on a host-only 192.x.x.x/24, with a second NAT interface for egress. Pods use 10.x.x.x/16, because Calico's default pod range collides with the node subnet.

On the host-only network, which is the default path, my machine resolves names through a scoped resolver pointed at an in-cluster CoreDNS, and reaches a MetalLB layer-2 virtual IP. Every development service gets a *.internal.<domain> name and lands on Traefik, which serves one wildcard certificate from cert-manager (Let's Encrypt, DNS-01). DNS-01 is the only challenge that works here, because Let's Encrypt can never reach a host-only address.

To share a service, I add a one-label public hostname to the tunnel. The Cloudflare edge forwards it through the connection that two cloudflared replicas hold open, outbound over NAT,, which hand it to the same Traefik. Routes decide what is reachable from outside: the documentation site is public, but its Kubernetes API paths are not routable from the tunnel at all, and it reads the API with a read-only service account, an allowlist of resource kinds, and secret redaction.

Try it: take out the node running Traefik, lose the control plane, or replay the MetalLB storm. The shared documentation site stands in for any service. Timings are illustrative, built from Kubernetes defaults and lab notes.

Self-hosted Kubernetes lab topologyThe dev host, the only machine on the host-only network, resolves names on an in-cluster CoreDNS and reaches a MetalLB virtual IP. Services shared on demand are reached through a Cloudflare edge and a tunnel that cloudflared holds open outbound over the NAT interface. Both paths land on one Traefik, which routes to the shared labdocs service. labdocs reads the Kubernetes API on a single control-plane node. Images come from an in-cluster zot registry.Outside the clusterCluster: 3 nodes, host-only 192.x.x.x/24 + NAT egresstunnel via NATDNSread-only APIimagesCloudflare edgeone-label TLSDev hosthost-only networkcloudflared ×2outbound tunnelMetalLB VIPL2 speakerCoreDNS ×2internal zoneTraefik1 replicazot registryPVC on one workerlabdocsshared, 1 replicacp-masterAPI + etcd ×1
  • Primary / serving
  • Recovering
  • Degraded
  • Down
  • Replication / control

Pick a failure to see how the system detects and recovers from it.

Key decisions

Outbound tunnel instead of port forwarding

  • ingress
  • security

The cluster sits on a host-only network that nothing outside can reach. Some services still need to be shown to other people, without exposing the home WAN address.

  • Port forward through the VMware NAT and the home routerTwo forwarding hops, publishes the WAN address, and puts the cluster directly on the internet.
  • cloudflared as a Traefik sidecarCouples lifecycles, widens the blast radius and shares the credential with the ingress pod.
  • Separate cloudflared Deployment, 2 replicasChosenDials out over the NAT interface, needs no inbound path, has no Service or route of its own, and survives the loss of one replica.

Trade-off: The public hostname list now lives in the Cloudflare dashboard, not in git, because the tunnel uses a remotely-managed token.

Lifecycle

  1. Run kubectl diff against the live cluster, then kubectl apply. Numbered directories encode apply order: CRDs, config, RBAC, workloads, admission.
  2. Pin every applied image by digest and check it has an arm64 build.
  3. Prove the change with its functional gate, never "pod is Running": a bound PVC with a write/read Job, kubectl top returning data, a Certificate Ready with both names.

Failure modes

FailureBlast radiusDetectionRecovery
Worker running Traefik lostPublic and host traffic down ~6 minUsers, then NotReady nodeAutomatic reschedule after the 300 s toleration
Control plane lostNo scheduling or snapshots; app API pages degradeapiserver unreachableManual: restart the VM
Control-plane disk lostCluster state and the only snapshot goneapiserver unreachableNone today: snapshot is on the same disk
Calico webhook CA wiped by an applyEvery network policy write fails, cluster-widePolicy writes return TLS errorsRe-patch the CA; the field is now omitted from the manifest
MetalLB reconcile stormapiserver saturated at ~900 req/s; serving unaffectedHost fans, then a per-resource request breakdownDelete the ServiceL2Status CRD after every MetalLB apply
WireGuard MTU mismatchImage pulls fail; no new pod can startRequests succeed from one worker and fail from the otherRolled back the same day
Kubelet serving cert not approvedkubectl top silently fails for that nodePending CSRManual approve after checking the CSR identity
Staging certificate on an HSTS-preloaded domainBrowsers hard-block every hostFirst human visitCut over to the production issuer

Results

0inbound paths: host-only network plus egress-only NAT
901.8 → 5.9apiserver req/s after fixing the MetalLB storm
171 → 73CRDs after removing Rancher
~90 swildcard certificate reissue over DNS-01

What I'd change

I would copy the etcd snapshot off the control-plane node and test a restore end to end. Today the backup protects against a corrupt database, not against losing the disk it sits on.

I would run Traefik as two replicas with anti-affinity and a PodDisruptionBudget. It is the one component both the public and host-only paths depend on.

I would automate the MetalLB CRD deletion as a post-apply step, and the kubelet serving CSR approval behind an identity check, instead of relying on remembering either.