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
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
cloudflaredDeployment is hard-coded to one replica with fixed resource requests. Both should be fields onChuteInstance, 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: tcpfor 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.