A small Kubernetes operator that does one thing: takes a ChuteInstance and
makes the Service it points at reachable on the public internet, behind
Cloudflare Access email OTP, in about thirty seconds.
It started because I had something running on my laptop — an AI experiment, nothing serious — and I wanted to show it to one person. The usual options felt heavy: stand up a real deployment, mess with ngrok, fight a reverse proxy, write down credentials, share a link that would die when my laptop slept. I just wanted a URL my friend could open, that nobody else could.
So I wrote the operator. Now the same kubectl apply works for every
half-finished thing I ever want to put in front of someone.
What it does
apiVersion: chutes.troubleshat.com/v1alpha1
kind: ChuteInstance
metadata:
name: demo
spec:
configRef: default
hostname: demo
service:
name: my-service
port: 8080
access:
allowedEmails:
- friend@example.com
sessionDuration: "24h"
Apply that and you get demo.mydomain.com — protected by email OTP, only
that one address can sign in. Delete the resource and every Cloudflare
artifact it created tears itself back down.
Architecture
ChuteConfig (credentials, cluster-scoped) and ChuteInstance (one per service, namespaced).Why I built it
The thing I wanted to expose was already in Kubernetes, so the ingress
should be too. The experiment was running in a kind cluster on my
laptop — manifests in a git repo next to the code. The natural way to put
a Service on the internet, when everything else about it is declared as
YAML, is another piece of YAML in the same pile. Not a separate tunnel
config in some other tool, not a manual cloudflared command out-of-band,
not a Terraform sidecar — a Kubernetes resource that lives next to the
workload it exposes. Once I framed it that way, "write a CRD" stopped
sounding like overkill and started sounding like the only honest answer.
Sharing a thing running on my laptop should be one command. Most of my
side projects never make it past localhost:3000. The friction of putting
them somewhere a friend can click — even temporarily — is enough that I
just don't. Chute makes that friction zero: kind cluster on the laptop,
operator running, apply a ChuteInstance, send the link. When I close the
laptop the tunnel goes dormant; when I open it again the link works.
Cloudflare is free at this scale. Tunnels are free. Access is free for the first fifty seats. DNS is free. So for "me plus a handful of people I want to show things to," the entire stack costs zero dollars — and the protection is real: it's an OTP gate at the edge, not a security-by- obscurity URL. (At company scale this gets expensive fast, which is why my public sites won't use Access — but for homelab and one-friend-at-a-time demos, it's the best deal on the internet.)
An operator like this used to be a short project of its own. The interesting surprise was how doable it was. Writing a Kubernetes operator used to mean signing up for a real undertaking — controller-runtime, kubebuilder markers, deepcopy generation, the Cloudflare API surface, finalizers, status conditions, RBAC. With an LLM in the loop the scaffolding evaporated; I spent my time on the decisions (how to model the CRDs, where to store state for resumability, what to garbage-collect with owner refs vs. finalizers) instead of on boilerplate. The whole thing is about 1,500 lines of Go.
The two CRDs
ChuteConfig (cluster-scoped) holds the Cloudflare credentials — a
reference to a Secret with the API token, plus the account ID, zone ID, and
base domain. One config can be shared by any namespace. The controller
verifies the token by making sure an OTP identity provider exists on the
account (creating one if not).
ChuteInstance (namespaced) is the per-service resource. It names the
local Service to expose, a hostname, and an Access policy (allowed emails
and/or domains).
Splitting them this way means the secret-bearing resource lives in one trusted namespace, and the day-to-day "expose this thing" resources are just plain YAML in whichever namespace you're working in.
The reconciliation trick worth talking about
The interesting bit is that creating a ChuteInstance means walking through five sequential steps against the Cloudflare API — create a tunnel, write its ingress config, create a DNS record, create an Access app, create an Access policy — plus a Kubernetes Deployment at the end. Any of those steps can fail. A naive operator would just retry from the top — and either create duplicates or leak resources you can't find later.
Chute writes the Cloudflare resource ID into .status after each step:
status:
tunnelId: "8c4a..."
dnsRecordId: "4f10..."
accessAppId: "b39e..."
accessPolicyId: "0e2d..."
ready: true
Every reconciliation pass checks the status first. If tunnelId is set,
skip step 1. If dnsRecordId is set, skip step 3. So a restart mid-way
through — operator crash, network blip, rate-limit retry — picks up exactly
where it left off without ever creating two of anything.
Deletion runs the same idea in reverse, gated by a finalizer
(chutes.troubleshat.com/cleanup): the operator deletes the Access policy,
then the Access app, then the DNS record, then the tunnel, and only then
removes the finalizer so Kubernetes can actually delete the object. The
in-cluster cloudflared Deployment and tunnel-token Secret are owner-
referenced to the ChuteInstance, so they get garbage-collected for free.
Find-or-create everywhere
The Cloudflare client (a ~500-line hand-rolled wrapper over the v4 REST
API, no SDK) exposes EnsureTunnel, EnsureDNSRecord, EnsureAccessApp,
EnsureAccessPolicy. Each one lists first, returns the existing resource
if it matches by name or domain, and only creates when nothing's there.
Combined with status-tracked step IDs, this means any path through the
reconciler converges on the same target state, whether you're starting
fresh, recovering from a half-finished run, or rebuilding state after
someone manually deleted the operator's database.
What I'd change
The cloudflared Deployment is hard-coded to a single replica with fixed
resource requests — replicas and resources should both be fields on
ChuteInstance so things that actually need HA can ask for it. The Cloudflare client doesn't yet handle rate-limit
backoff explicitly (it relies on the controller-runtime requeue), which is
fine at homelab volume but would need work for anything bigger. And I'd
like a ChuteInstance.spec.protocol: tcp mode for non-HTTP services — the
tunnel can do it, the operator just doesn't expose it yet.
Status
Running. Tagged through v0.0.4, deploys with a single kubectl apply of
the manifest from the GitHub release. Code is at
nabkey/chute.