Chute.

A Kubernetes operator that turns one CRD into a public URL — Cloudflare Tunnel, DNS, Access OTP, and a cloudflared pod, all from a single kubectl apply. Built because I wanted to share an AI experiment on my laptop in five minutes.

↗ https://github.com/nabkey/chute

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

CONTROL PLANEChuteInstancecustom resourcechute operatorgo · controller-runtimecloudflare APItunnel · DNS · Accesscloudflared podin clusterDATA PLANEinternetyour friend's browsercloudflare edgeAccess gateemail OTPcloudflaredin-cluster podyour servicesvc.cluster.localThe operator (top) creates Cloudflare resources and the cloudflared Deployment.Public traffic (bottom) only flows after Access verifies the visitor's email OTP.Delete the ChuteInstance and a finalizer tears the Cloudflare side down in reverse.
Two CRDs: 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.