If you run anything serious on Kubernetes, you've already learned that "the agent is just a container, you can run it anywhere" is a half-truth. Yes, you can run the agent as a sidecar or a standalone Deployment. But you also need: a Secret for the auth token, a way to surface the resolved public URL back to your other tooling, lifecycle coupling between the agent Pod and the upstream endpoint, drift detection when someone edits things in the dashboard, and (if you're being honest) a way to say all of this in YAML that doesn't look like punishment.
Today we're shipping the Ngris Kubernetes Operator. One CRD, three lines of YAML, and your Service is exposed:
apiVersion: ngris.io/v1alpha1
kind: Tunnel
metadata: { name: hello-api }
spec:
serviceRef: { name: hello-api, port: 8080 }
host: hello.example.com
protocol: http
region: eu-north-1
Apply it. The operator resolves the host, creates the upstream endpoint, mints a per-Tunnel auth token, drops it in a Secret, renders an agent Deployment that forwards to your Service, and reflects the resolved URL in status.publicURL. kubectl delete tunnel hello-api removes the agent Pod and the Secret. The upstream endpoint is preserved by default (a single cluster shouldn't be the unique authority over a resource that can also be managed from the dashboard or Terraform); opt into cascade with the ngris.io/delete-endpoint-on-delete annotation when you actually want ephemeral, per-PR-style cleanup.
Why an Operator (and Not Just a Helm Chart)
Helm gives you a deployable agent. An operator gives you the language to say "I want a tunnel" instead of "I want a tunneling Pod, plus a Secret to feed it, plus another tool to reconcile what gets reflected on the Ngris side."
That distinction matters more than it sounds:
- GitOps actually works. ArgoCD / Flux can sync your Tunnel CRs and meaningfully wait on
Ready=True. The condition is real — it means the endpoint is alive AND the agent has at least one ready replica. - Deletion is honest. Owner references tear down the agent Pod and Secret with the Tunnel — no Kubernetes-side orphans. Upstream endpoints are preserved by default (the same endpoint may live in the dashboard or another cluster); flip the cascade annotation on for ephemeral previews and the operator removes the upstream row too.
- Drift is detected. If someone clicks "delete" in the Ngris dashboard, the operator notices on its periodic re-check and recreates the endpoint. Spec is the source of truth.
- Multi-resource invariants hold. You can attach a
NgrisTrafficPolicyand anNgrisMTLSAttachmentto the same Tunnel from separate YAMLs and the operator wires them up the moment the parent endpoint exists.
Ten CRDs, Composable
The headline CRD is Tunnel. Most users only ever write Tunnel manifests. The rest are opt-in pieces that attach to a Tunnel by name:
| Kind | What it does |
|---|---|
| Tunnel | Expose an in-cluster Service via Ngris (inline-creates an endpoint OR attaches via endpointRef) |
| TunnelMatrix | Fan one template into N children (multi-env / multi-region) |
| NgrisDomain | Register a custom domain + surface verification records |
| NgrisEndpoint | Strict, lifecycle-isolated endpoint (cascade-delete, fail-on-exist) — the platform/app split companion to Tunnel.spec.endpointRef |
| NgrisCertificate | Upload a TLS cert from a kubernetes.io/tls Secret |
| NgrisTrafficPolicy | Phase-grouped rules — rate-limit, IP allowlist, JWT, header rewrites |
| NgrisClientCA | Register a mTLS client CA from a Secret |
| NgrisMTLSAttachment | Bind CA set + mode (disabled / optional / required) to an endpoint |
| NgrisAuthPolicy | Basic / bearer / OAuth auth on an endpoint |
| NgrisRoutingRule | Route requests to a specific agent by path, header, cookie, IP, or method |
The split exists for a reason: traffic policies have their own lifecycle. You shouldn't have to edit your Tunnel CR every time you want to tighten a rate limit. And your security team can own the NgrisAuthPolicy and NgrisMTLSAttachment CRs in their own repo while your app team owns the Tunnel. NgrisEndpoint takes the same idea further: a platform team can manage strict-create endpoints with cascading deletes, and app teams reference them by name from their Tunnels.
Migration Paths That Don't Suck
If you've already standardized on something, we meet you there.
From plain Ingress: set spec.ingressClassName: ngris on your existing Ingress and we translate each (host × path → service) into a Tunnel CR under the hood. Lossy — Ingress can't express rate limits or mTLS — but it gets you to working state in one annotation, and you can evolve to the full Tunnel + policy stack when you're ready.
From Gateway API: point a GatewayClass at ngris.io/gateway-controller and write standard HTTPRoute YAML. RequestHeaderModifier / ResponseHeaderModifier filters auto-generate a sibling NgrisTrafficPolicy. The Gateway API is the right place to be eventually; we just don't make you wait until your whole org has migrated.
The Boring But Necessary Things
- Token rotation. Per-Tunnel agent tokens are rotated automatically every 90 days. You don't write a CronJob, the operator handles it.
- Drift detection. Periodic 5-minute requeue catches out-of-band edits.
- Admission webhook. Bad Tunnel CRs (missing region, invalid protocol, duplicate host in the namespace) get rejected at apply time, not 30 seconds later in the status conditions.
- Prometheus metrics.
ngris_reconcile_total{kind,result}andngris_api_requests_total{verb,status}ship out of the box. ServiceMonitor template included. - Conditional Gateway API support. If the Gateway API CRDs aren't installed in your cluster, the operator skips those reconcilers cleanly instead of crash-looping. Install them later, restart the operator, they light up.
A Real Example
Here's a complete production-shaped stack — Tunnel exposing a Service, anonymous rate limit, mTLS for a partner integration, all in one apply:
---
apiVersion: ngris.io/v1alpha1
kind: NgrisDomain
metadata: { name: example-com }
spec:
name: example.com
provider: none
---
apiVersion: ngris.io/v1alpha1
kind: Tunnel
metadata: { name: hello-api, namespace: default }
spec:
serviceRef: { name: hello-api, port: 8080 }
host: hello.example.com
protocol: http
region: eu-north-1
---
apiVersion: ngris.io/v1alpha1
kind: NgrisTrafficPolicy
metadata: { name: anon-rate-limit, namespace: default }
spec:
endpointRef: { name: hello-api }
on_http_request:
- type: rate-limit
config: { requests_per_minute: 60, burst: 100, key: ip }
---
apiVersion: ngris.io/v1alpha1
kind: NgrisMTLSAttachment
metadata: { name: hello-api-mtls, namespace: default }
spec:
endpointRef: { name: hello-api }
mode: required
caRefs: [{ name: internal-corp-ca }]
Three CRDs, three ownership boundaries, one apply. That's what an operator buys you that a Helm chart can't.
Try It
helm install ngris-operator oci://ghcr.io/ngris/charts/ngris-operator \
--namespace ngris-operator-system --create-namespace \
--version 0.1.0
Full install + sample manifests + the kind-based E2E suite live at github.com/ngris/ngris-operator. Docs: docs.ngris.io/kubernetes-operator.
If you're running tunnels in production on Kubernetes today and the agent-as-sidecar setup is starting to feel like load-bearing duct tape — give the operator a swap. The CRD shape is what you wish your existing tooling looked like.