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. Give it a ChuteInstance and the Service it points at is on the public internet in about thirty seconds, behind Cloudflare Access email OTP.

I had an AI experiment running on my laptop and wanted to show it to one person. A real deployment, ngrok, a reverse proxy, shared credentials, or a link that dies when the laptop sleeps all felt heavy. I wanted a URL my friend could open, that nobody else could. Now the same kubectl apply works for anything half-finished I 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, and only that one email address can sign in. Delete the resource and everything it created on Cloudflare gets torn 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 an operator

Why write an operator at all? It was obvious from the beginning, because we were working in Kubernetes and I didn't want to leave Kubernetes to set up this one resource.

The workload was already in Kubernetes. The experiment ran in a kind cluster on my laptop, with manifests in a git repo next to the code. The ingress is another piece of YAML that lives next to the workload it exposes.

Sharing should be one command. Most of my side projects never get past localhost:3000, because putting them where a friend can click is just enough friction that I don't. With the operator running in the laptop's kind cluster, I apply a ChuteInstance and send the link. Close the laptop and the tunnel goes dormant. Open it and the link works again.

It's free at this scale. Tunnels and DNS are free, and Access is free for the first fifty seats, so "me plus a handful of people" costs nothing. The OTP gate sits at the edge, so the protection is real rather than an obscure URL. At company scale Access gets expensive fast, which is why my public sites don't use it.

An LLM did the boilerplate. controller-runtime, kubebuilder markers, deepcopy generation, finalizers, status conditions, RBAC and the Cloudflare API used to make an operator a project of its own. I spent the time on the decisions instead. How to model the two CRDs, where to keep state so a half-finished run can resume, and what to clean up with owner refs versus finalizers. It's about 1,500 lines of Go.

Two CRDs

  • ChuteConfig (cluster-scoped) holds the Cloudflare credentials. That's a reference to a Secret with the API token, plus the account ID, zone ID and base domain. Any namespace can use it. The controller checks the token by making sure an OTP identity provider exists on the account, and creates one if it doesn't.
  • ChuteInstance (namespaced) is one per service. It names the local Service, a hostname, and an Access policy of allowed emails and/or domains.

The secret-bearing resource stays in one trusted namespace. Everything else is plain YAML wherever you're working.

Resumable reconciliation

A new ChuteInstance takes five sequential Cloudflare calls (create a tunnel, write its ingress config, create a DNS record, create an Access app, create an Access policy), then a Kubernetes Deployment. Any step can fail, and retrying from the top would create duplicates or leak resources.

So Chute writes each Cloudflare resource ID into .status as it goes:

status:
  tunnelId:        "8c4a..."
  dnsRecordId:     "4f10..."
  accessAppId:     "b39e..."
  accessPolicyId:  "0e2d..."
  ready:           true

Every pass checks status first. If tunnelId is set it skips step 1, if dnsRecordId is set it skips step 3, and so on. An operator crash, network blip or rate-limit retry picks up where it left off without creating two of anything.

Deletion runs in reverse behind 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. The in-cluster cloudflared Deployment and its tunnel-token Secret are owner-referenced to the ChuteInstance, so Kubernetes garbage-collects them.

The Cloudflare client is a ~500-line hand-rolled wrapper over the v4 REST API, no SDK. EnsureTunnel, EnsureDNSRecord, EnsureAccessApp and EnsureAccessPolicy each list first, return a match by name or domain, and only create when nothing's there. With the status IDs, every path through the reconciler converges on the same state, whether it's a fresh start or a half-finished run.

What I'd change

  • The cloudflared Deployment is hard-coded to one replica with fixed resource requests. Both should be fields on ChuteInstance, so something that needs HA can ask for it.
  • The client has no explicit rate-limit backoff and leans on the controller-runtime requeue. Fine at homelab volume, not beyond.
  • I'd like ChuteInstance.spec.protocol: tcp for non-HTTP services. The tunnel can do it; the operator doesn't expose it yet.

Status

Running. Tagged through v0.0.4, and it deploys with one kubectl apply of the manifest from the GitHub release. Code is at nabkey/chute.