|
| 1 | ++++ |
| 2 | +title = "NGINX Ingress Is Dead. Here's What to Do Next." |
| 3 | +description = "NGINX Ingress Controller maintenance halted in March 2026, with no more releases or security patches. We built a Gateway API controller for Zentinel. Here's the migration story." |
| 4 | +date = 2026-03-14 |
| 5 | +[taxonomies] |
| 6 | +tags = ["gateway-api", "kubernetes", "nginx", "migration", "ingress"] |
| 7 | ++++ |
| 8 | + |
| 9 | +We've been watching the NGINX Ingress situation for a while. When Kubernetes [announced the retirement](https://kubernetes.io/blog/2025/11/11/ingress-nginx-retirement/) back in November, the writing was on the wall, but there was still time. That time is up. As of March 2026, NGINX Ingress Controller is no longer maintained. No releases, no bugfixes, no security patches. The repositories are moving to `kubernetes-retired/`. |
| 10 | + |
| 11 | +If you're running NGINX Ingress in production today, your ingress layer is now unpatched software sitting at your network edge. That's not a comfortable place to be. |
| 12 | + |
| 13 | +We spent the last few weeks building a Gateway API controller for Zentinel. This post is about why we built it, what we learned along the way, and how to actually migrate. |
| 14 | + |
| 15 | +## What an ingress controller actually does |
| 16 | + |
| 17 | +Before getting into the migration, it's worth stepping back and thinking about what an ingress controller is really doing for you, because the answer matters for choosing what comes next. |
| 18 | + |
| 19 | +Your Kubernetes cluster runs pods. Those pods have internal IPs that change every time they restart. Services give them stable names, but those names only work inside the cluster. To get external traffic to your applications, something needs to sit at the boundary, accept incoming connections, terminate TLS, look at the request, figure out which service it belongs to, and proxy it there. |
| 20 | + |
| 21 | +That's the ingress controller. It's a reverse proxy that's aware of your Kubernetes resources. It watches for Ingress (or now, Gateway API) objects, reads the routing rules you've declared, and configures itself to match. When you add a new hostname, change a path, or rotate a certificate, the controller picks up the change and reconfigures the proxy without dropping connections. |
| 22 | + |
| 23 | +The reason this matters so much is that it's the one component that touches every single inbound request. It's your TLS termination point. It's where your rate limiting happens. It's where your WAF runs. If the ingress controller has a bug, a memory leak, or a security vulnerability, every application behind it is affected. It's arguably the most critical piece of infrastructure in a production cluster, and the one that needs the most attention to performance, safety, and correctness. |
| 24 | + |
| 25 | +NGINX Ingress handled this by running NGINX (the C-based web server) inside a container, with a Go controller that watched Kubernetes resources and regenerated `nginx.conf` whenever something changed. It worked, but the architecture had a fundamental tension: NGINX is configured through text files, and Kubernetes resources are structured data. The controller had to translate one into the other, and the `snippet` annotations were the escape hatch for when the translation wasn't expressive enough. That escape hatch is what eventually brought the project down. |
| 26 | + |
| 27 | +## How we got here |
| 28 | + |
| 29 | +The story of NGINX Ingress is honestly kind of sad. For years it was *the* way to get traffic into a Kubernetes cluster. Millions of deployments. And for most of that time, one person was maintaining it. Nights and weekends, volunteer work, for a project that half the internet depends on. |
| 30 | + |
| 31 | +The technical debt accumulated in ways that are obvious in hindsight. The `server-snippet` and `configuration-snippet` annotations were the big one. They let anyone with Ingress create permissions inject raw NGINX config into the proxy. It was incredibly flexible. It was also CVE-2021-25742 and every variant that followed. The same feature that made NGINX Ingress the "just add an annotation" solution is what made it a security liability. |
| 32 | + |
| 33 | +When the maintainers tried to find help or build a successor (InGate), it didn't gain traction. And so here we are. |
| 34 | + |
| 35 | +## The Gateway API, briefly |
| 36 | + |
| 37 | +The Kubernetes project's answer is the [Gateway API](https://gateway-api.sigs.k8s.io/), a set of CRDs that replace the Ingress resource. The core idea is separating concerns: |
| 38 | + |
| 39 | +- **GatewayClass**, which says who provides the implementation (think `StorageClass` for networking) |
| 40 | +- **Gateway**, which defines the infrastructure: ports, protocols, TLS |
| 41 | +- **HTTPRoute, GRPCRoute, TLSRoute**, which define the actual routing rules |
| 42 | + |
| 43 | +Platform teams own Gateways. Application teams own Routes. Nobody injects raw config into anyone else's namespace. It's a cleaner model, and it's where Kubernetes networking is heading. |
| 44 | + |
| 45 | +The Gateway API is also more expressive than the old Ingress resource. You can match on headers, query parameters, and methods, not just paths. You can split traffic between backends by weight. You can modify request and response headers. You can do cross-namespace routing with explicit permission grants. Most of the things that required vendor-specific annotations on NGINX Ingress are first-class fields in the Gateway API. |
| 46 | + |
| 47 | +## Why we built this, and why Pingora matters |
| 48 | + |
| 49 | +We're not going to pretend Zentinel is the only option. Envoy Gateway, Contour, and Traefik all have Gateway API implementations. They're solid projects. |
| 50 | + |
| 51 | +But we had a specific itch to scratch, and it starts with the proxy itself. |
| 52 | + |
| 53 | +Zentinel is built on [Pingora](https://github.com/cloudflare/pingora), Cloudflare's Rust-based proxy framework. Cloudflare built Pingora because they hit the limits of NGINX at scale, processing trillions of requests, and needed something with better memory safety, lower tail latency, and more control over connection handling. They open-sourced it in 2024, and we've been building on it ever since. |
| 54 | + |
| 55 | +This matters for the ingress controller story because the proxy layer is where the performance lives. Every request that enters your cluster passes through this code. Pingora gives us an async, multi-threaded Rust runtime with zero-copy I/O, connection pooling per upstream, and HTTP/2 multiplexing out of the box. Because it's Rust, there are no garbage collection pauses, no null pointer exceptions in the request path, no data races. The compiler enforces these things at build time, not at 3am when your pager goes off. |
| 56 | + |
| 57 | +We've [benchmarked Zentinel against NGINX, Envoy, HAProxy, and Caddy](/benchmarks/). The numbers speak for themselves, but the short version is that Zentinel consistently shows the lowest p99 latency and highest throughput in our test suite. Our pure-Rust WAF agent processes 912,000 requests per second on the same hardware where ModSecurity manages around 30,000. These aren't theoretical numbers; they come from our [open-source benchmark suite](https://github.com/zentinelproxy/zentinel-bench) that anyone can reproduce. |
| 58 | + |
| 59 | +But performance wasn't actually the main reason we built this controller. The main reason was architectural. |
| 60 | + |
| 61 | +Zentinel was designed around a principle that happens to be exactly what killed NGINX Ingress: custom logic should never run inside the proxy process. In Zentinel, if you need a WAF, auth, rate limiting, or any custom behavior, it runs in an external [agent](/docs/agents/), a separate process that talks to the proxy over Unix domain sockets or gRPC. If the agent crashes, it restarts. The proxy keeps serving traffic. You can write agents in Rust, Go, Python, TypeScript, Elixir, or any language that can speak our protocol. We have [SDKs for over a dozen languages](https://github.com/zentinelproxy). |
| 62 | + |
| 63 | +This is the opposite of the snippets model. There's no annotation that lets you inject arbitrary configuration into the proxy. Not because we removed it after a CVE, but because the architecture never had a place for it. The proxy's config is declarative and validated at load time. Complex logic lives in agents, which are crash-isolated by OS process boundaries. The blast radius of a bad agent is that one agent restarts; the proxy and every other agent keep running. |
| 64 | + |
| 65 | +When you combine this with the Pingora foundation, what you get is an ingress controller where the data plane is written in safe, compiled code, the custom logic is crash-isolated, and the configuration can't be injected from YAML annotations. That's a meaningfully different posture from NGINX Ingress, and it's the reason we thought it was worth building a controller rather than just pointing people at the alternatives. |
| 66 | + |
| 67 | +## What the controller does |
| 68 | + |
| 69 | +We built [zentinel-gateway](https://github.com/zentinelproxy/zentinel/pull/141), a Kubernetes controller that watches Gateway API resources and translates them into Zentinel's internal config. The controller is itself written in Rust, using the [kube-rs](https://kube.rs) client library, and runs as a separate binary from the proxy. |
| 70 | + |
| 71 | +Here's what it handles: |
| 72 | + |
| 73 | +| Resource | What it does | |
| 74 | +|----------|-------------| |
| 75 | +| GatewayClass | Claims `zentinelproxy.io/gateway-controller`, sets status conditions | |
| 76 | +| Gateway | Translates listeners, resolves TLS certificates from K8s Secrets | |
| 77 | +| HTTPRoute | Path, header, method, query param matching. Header modification filters. Weighted traffic splitting. | |
| 78 | +| GRPCRoute | Service/method matching, forces HTTP/2, gRPC health checks | |
| 79 | +| TLSRoute | SNI-based passthrough routing | |
| 80 | +| ReferenceGrant | Cross-namespace reference validation | |
| 81 | +| Ingress (compat) | Legacy Ingress resources with `ingressClassName: zentinel` | |
| 82 | + |
| 83 | +The translation layer is where the interesting work happens. When you create an HTTPRoute, the controller reads the matches, filters, and backend refs, then builds the equivalent Zentinel `RouteConfig` and `UpstreamConfig` objects. It resolves Kubernetes service names to DNS entries (`my-service.my-namespace.svc.cluster.local`), sets up health checks appropriate to the protocol (HTTP for web backends, gRPC health protocol for gRPC services, plain TCP for TLS passthrough), and assigns weights for traffic splitting. The translated config is pushed into the proxy through an atomic swap, so there's no moment where the proxy is running with a half-applied configuration. |
| 84 | + |
| 85 | +For TLS, the controller watches `kubernetes.io/tls` Secrets referenced by Gateway listeners. When it finds one, it extracts the certificate and private key, writes them to disk with 0600 permissions, and configures the listener to use them. If the Secret changes (say, cert-manager renews a certificate), the controller picks up the change and refreshes the files automatically. We also support multiple certificates per listener for SNI-based certificate selection. |
| 86 | + |
| 87 | +On the operational side, the controller supports Lease-based leader election for running multiple replicas safely, and exposes Prometheus metrics for monitoring reconciliation latency, error rates, and the number of active resources. |
| 88 | + |
| 89 | +## Migrating, step by step |
| 90 | + |
| 91 | +### 1. Install the Gateway API CRDs |
| 92 | + |
| 93 | +```bash |
| 94 | +kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml |
| 95 | +``` |
| 96 | + |
| 97 | +### 2. Install the Zentinel Gateway controller |
| 98 | + |
| 99 | +```bash |
| 100 | +helm install zentinel-gateway oci://ghcr.io/zentinelproxy/charts/zentinel-gateway \ |
| 101 | + --namespace zentinel-system \ |
| 102 | + --create-namespace |
| 103 | +``` |
| 104 | + |
| 105 | +This creates a `GatewayClass` named `zentinel` and starts the controller. The Helm chart sets up the RBAC rules the controller needs: read access to Gateway API resources, Secrets, Services, and Endpoints, plus write access to status subresources and Leases (for leader election). |
| 106 | + |
| 107 | +### 3. Create a Gateway |
| 108 | + |
| 109 | +This is the equivalent of deploying the NGINX Ingress controller itself. A Gateway declares what ports to listen on, what protocols to speak, and where to find TLS certificates: |
| 110 | + |
| 111 | +```yaml |
| 112 | +apiVersion: gateway.networking.k8s.io/v1 |
| 113 | +kind: Gateway |
| 114 | +metadata: |
| 115 | + name: main |
| 116 | +spec: |
| 117 | + gatewayClassName: zentinel |
| 118 | + listeners: |
| 119 | + - name: http |
| 120 | + port: 80 |
| 121 | + protocol: HTTP |
| 122 | + - name: https |
| 123 | + port: 443 |
| 124 | + protocol: HTTPS |
| 125 | + tls: |
| 126 | + mode: Terminate |
| 127 | + certificateRefs: |
| 128 | + - name: wildcard-tls # your existing TLS Secret works here |
| 129 | +``` |
| 130 | +
|
| 131 | +The controller picks this up, validates that the GatewayClass belongs to us, resolves the TLS Secret, and configures the proxy's listeners. You'll see `Accepted` and `Programmed` conditions on the Gateway's status within a few seconds. |
| 132 | + |
| 133 | +### 4. Convert your Ingress resources to HTTPRoutes |
| 134 | + |
| 135 | +A typical NGINX Ingress resource: |
| 136 | +```yaml |
| 137 | +apiVersion: networking.k8s.io/v1 |
| 138 | +kind: Ingress |
| 139 | +metadata: |
| 140 | + name: my-app |
| 141 | + annotations: |
| 142 | + nginx.ingress.kubernetes.io/rewrite-target: / |
| 143 | +spec: |
| 144 | + ingressClassName: nginx |
| 145 | + rules: |
| 146 | + - host: app.example.com |
| 147 | + http: |
| 148 | + paths: |
| 149 | + - path: /api |
| 150 | + pathType: Prefix |
| 151 | + backend: |
| 152 | + service: |
| 153 | + name: api-service |
| 154 | + port: |
| 155 | + number: 8080 |
| 156 | +``` |
| 157 | + |
| 158 | +Becomes: |
| 159 | +```yaml |
| 160 | +apiVersion: gateway.networking.k8s.io/v1 |
| 161 | +kind: HTTPRoute |
| 162 | +metadata: |
| 163 | + name: my-app |
| 164 | +spec: |
| 165 | + parentRefs: |
| 166 | + - name: main |
| 167 | + hostnames: |
| 168 | + - app.example.com |
| 169 | + rules: |
| 170 | + - matches: |
| 171 | + - path: |
| 172 | + type: PathPrefix |
| 173 | + value: /api |
| 174 | + backendRefs: |
| 175 | + - name: api-service |
| 176 | + port: 8080 |
| 177 | +``` |
| 178 | + |
| 179 | +The routing intent is the same, just expressed as proper API fields instead of annotations. Notice that the `parentRefs` field explicitly says which Gateway this route attaches to. In NGINX Ingress, the binding between Ingress and controller was implicit (through the class name). In the Gateway API, it's explicit, and the Gateway can control which namespaces are allowed to attach routes to it. |
| 180 | + |
| 181 | +### 5. Or don't convert yet |
| 182 | + |
| 183 | +Here's something we thought was important: you shouldn't have to rewrite all your Ingress manifests on day one. The controller includes an Ingress compatibility shim. Set `ingressClassName: zentinel` on your existing Ingress resources and Zentinel will handle them alongside Gateway API resources. The shim translates each Ingress rule into the equivalent internal route configuration, so your existing host/path/backend mappings keep working. |
| 184 | + |
| 185 | +This means you can migrate incrementally. Start with the Gateway and one HTTPRoute. Verify it works. Then move the next service over. The old Ingress resources and the new HTTPRoutes coexist in the same controller, routing to the same backends. |
| 186 | + |
| 187 | +## The annotation mapping |
| 188 | + |
| 189 | +If you've been running NGINX Ingress for a while, you probably have annotations everywhere. We put together a [mapping guide](https://github.com/zentinelproxy/zentinel/blob/main/crates/gateway/docs/migration-from-nginx-ingress.md) that covers the common ones: |
| 190 | + |
| 191 | +- **Path types and host matching** map directly to HTTPRoute matches |
| 192 | +- **TLS certificates** move from `spec.tls[].secretName` to Gateway listener `certificateRefs` |
| 193 | +- **Header manipulation** (`proxy-set-headers`) becomes `RequestHeaderModifier` filters |
| 194 | +- **Canary deployments** (`canary-weight` annotation) become weighted `backendRefs`, which is honestly a nicer API |
| 195 | +- **Cross-namespace routing** (`ExternalName` services) becomes `ReferenceGrant`, which is more explicit about what's allowed |
| 196 | +- **Custom logic** (`server-snippet`, Lua) becomes Zentinel agents, which is a bigger change but a safer one |
| 197 | + |
| 198 | +The guide has before/after YAML for each pattern. |
| 199 | + |
| 200 | +The one area where there isn't a clean 1:1 mapping is the snippet annotations. If you were using `server-snippet` to add custom NGINX directives, there's no Gateway API equivalent because the whole point of the Gateway API is to not have that escape hatch. In Zentinel, the answer is agents. If you need to add custom headers based on request content, validate JWTs, call an external authorization service, or run any kind of request-time logic, you write a small agent that implements the behavior you need. It's more work than pasting a snippet into an annotation, but it's also the kind of work that doesn't produce CVEs. |
| 201 | + |
| 202 | +## What's not done yet |
| 203 | + |
| 204 | +We want to be upfront about where this stands. The controller handles the core Gateway API conformance profile and we're confident in the translation layer. But there are things we haven't built yet: |
| 205 | + |
| 206 | +- **RequestRedirect and URLRewrite filters** are not translated yet |
| 207 | +- **The official Gateway API conformance test suite** (which is Go-based) hasn't been run against this controller. We have integration test scaffolding and the conformance test runner documented, but passing the full suite is still ahead of us. |
| 208 | +- **BackendTLSPolicy** for upstream mTLS |
| 209 | +- **Custom policy CRDs** for attaching Zentinel-specific features (WAF, rate limiting, agents) to Gateway API resources through the Gateway API's policy attachment model |
| 210 | + |
| 211 | +We'll get to these. We wanted to ship the core functionality now because people need a migration path today, not in six months. |
| 212 | + |
| 213 | +## Trying it out |
| 214 | + |
| 215 | +The Helm chart is the fastest way: |
| 216 | + |
| 217 | +```bash |
| 218 | +helm install zentinel-gateway oci://ghcr.io/zentinelproxy/charts/zentinel-gateway \ |
| 219 | + --namespace zentinel-system --create-namespace |
| 220 | +``` |
| 221 | + |
| 222 | +If you hit issues, the [migration guide](https://github.com/zentinelproxy/zentinel/blob/main/crates/gateway/docs/migration-from-nginx-ingress.md) has more detail, and the [PR](https://github.com/zentinelproxy/zentinel/pull/141) has the full implementation if you want to read the code. The controller is about 5,000 lines of Rust across the reconcilers, translator, leader election, metrics, and TLS handling. It's not a small project, but it's also not doing anything magical. Most of the complexity is in correctly mapping Gateway API semantics to Zentinel's config model, and in handling all the edge cases around cross-namespace references, status condition updates, and graceful certificate rotation. |
| 223 | + |
| 224 | +We'd genuinely appreciate feedback, especially from people migrating real NGINX Ingress setups. What annotations are you using that we haven't mapped? What broke? What did we get wrong? Open an [issue](https://github.com/zentinelproxy/zentinel/issues) and tell us. |
0 commit comments