# 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.

- Source: https://nabkey.com/tinkerings/chute
- Author: Chris Nabkey
- Published: 2026-05-10
- Built: 2026
- Status: shipped
- Stack: Go, Kubernetes, Operator pattern, Cloudflare Tunnels, Cloudflare Access, ko
- Project: 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

```yaml
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

<figure class="diagram">
  *Diagram: Chute architecture: a ChuteInstance custom resource drives the operator to provision a Cloudflare Tunnel, DNS record, Access app, and Access policy on Cloudflare, plus a cloudflared Deployment in the cluster. Public traffic flows from the internet through Cloudflare's Access gate and tunnel to the cloudflared pod, then to the target Service.* ([view it on nabkey.com](https://nabkey.com/tinkerings/chute))
  <figcaption>Two CRDs: <code>ChuteConfig</code> (credentials, cluster-scoped) and <code>ChuteInstance</code> (one per service, namespaced).</figcaption>
</figure>

## 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](https://github.com/nabkey/chute).
