Varnish Software Blog

Introducing the Varnish Gateway

Written by Per Buer | 5/19/26 5:00 PM

Varnish has not been a great fit for Kubernetes. There are Docker images, there are even Helm charts, they help lift varnishd into a cluster and call it a day, but that is a long way from being cloud native. We’ve been working on closing that gap. Varnish Gateway is a Kubernetes Gateway API implementation that uses Varnish as the data plane, and it is the way we want people to run Varnish in a Kubernetes cluster going forward.

The Gateway API itself is the part of Kubernetes that finally cleaned up ingress. You describe Gateways and HTTPRoutes as Kubernetes resources, and any conformant implementation, Nginx, HAProxy, Envoy and now Varnish, has to behave the same way. As someone whose product has historically been one of those opinionated middle boxes, I feel somewhat threatened by the disintermediation, but for you, the user, I see the value.

Varnish Gateway passes the official Gateway API v1.5 conformance suite. Standard HTTPRoute matching, header and query filters, path types, weighted traffic splitting, all of that works the way the spec says it should. What I want to write about here are the design choices that sit on top of that, what makes this work interesting.

Routing isn’t in the VCL

The naive way to bring Varnish to Kubernetes is to translate HTTPRoutes into VCL and reload Varnish every time a route changes. We considered this and dropped it quickly. VCL compilation is slow, has multiple failure modes, and a malformed CRD on the input side can produce VCL that won’t load at all, which is fatal for the whole gateway. You really do not want every route edit to be a compile step. You can just imagine how well such an application will deal with a pod flapping.

Instead, routing lives in a Rust VMOD called ghost that varnishd loads at startup. Ghost reads a JSON file describing routes and pod addresses, builds an in-memory router, and matches every incoming request against it. When a route or an EndpointSlice changes, the JSON is regenerated and ghost atomically swaps its internal router. No VCL compile. In-flight requests finish against the old router; new ones pick up the new one. Parsing the JSON takes milliseconds. The Rust ARC crates makes the atomicity safe and simple.

The pieces around ghost are deliberately small:

A cluster-wide operator watches Gateway and HTTPRoute resources and writes two artifacts into a ConfigMap: routing.json (host → service mappings) and main.vcl. A per-pod chaperone runs as PID 1 in the gateway pod, supervises varnishd, watches the ConfigMap and the EndpointSlices for every service referenced by a route, merges the EndpointSlice data into a ghost.json with concrete pod addresses, and drives the reloads. ghost does the per-request work inside Varnish itself.

Three small things, each with one job. The chaperone exists because varnishd on its own can’t watch Kubernetes resources. Ghost exists because generating VCL for routing is a bad idea.

Three classes of change, three reload paths

One place the Kubernetes fights the concept of caching is the generic approach to config changes. Spinning up a new pod and switching traffic, is effortless and safe, but will destroy your cache hit ratio. So, the Varnish Gateway will go out of its way to avoid restarts.

There are three reload paths, each matched to the smallest unit of change that actually needs to move:

The decision boundary is a single SHA-256 written to the Deployment’s pod template as varnish.io/infra-hash. It covers exactly the inputs varnishd can’t reconfigure on the fly: image, listener sockets, varnishd args, log config. If the hash doesn’t change, the pods don’t move, full stop. Everything else flows through one of the two hot paths.

 

Caching is opt-in

This is the design choice most people find counterintuitive, given that this is a Varnish product. Out of the box, Varnish Gateway sets pass on every request. No caching, no request coalescing, no stale serving. It is a plain reverse proxy until you explicitly say otherwise.

The reason is that Gateway API is built around patterns, blue/green deployments, canary rollouts, weighted traffic splits, that all assume the proxy faithfully forwards every request. If you cache a response from the canary backend, every subsequent request serves that cached copy regardless of the configured weight. The split becomes meaningless. Defaulting to cache-on would silently break the most useful things the Gateway API gives you.

To turn caching on for a route, you attach a VarnishCachePolicy to a Gateway, an HTTPRoute, or a named rule within a route. The policy follows the Gateway API Inherited Policy pattern, Gateway-level settings act as defaults, route-level settings override, rule-level settings override again. It carries the things you’d expect: TTL, either defaultTTL where the origin’s Cache-Control wins, or forcedTTL where it doesn’t, grace and keep for stale serving, a cacheKey stanza for header and query parameter handling, and a bypass block for per-request opt-outs. Stripping utm_source and friends from the cache key is one exclude: list.

  apiVersion: gateway.varnish-software.com/v1alpha1   kind: VarnishCachePolicy   metadata:     name: strip-utm   spec:     targetRef:       group: gateway.networking.k8s.io       kind: HTTPRoute       name: my-route     cacheKey:       queryParameters:         exclude:           - utm_source

Cache invalidation gets its own CRD: VarnishCacheInvalidation. PURGE for exact-URL removal, BAN for regex-pattern invalidation, both as one-shot resources you create and the operator garbage-collects after they’re applied. The Chaperone in each gateway executes the operation against its local Varnish and reports back through status.podResults. It batches, one CRI can carry many paths. Performance here will vary depending on how fast your Kubernetes control plane is. If you’re worried about performance, I’d recommend sticking with the traditional PURGE handling in Varnish Cache.

VCL when you need it

HTTPRoutes cover the Gateway API spec. They don’t cover everything Varnish can do. For anything more interesting, OIDC integration, request signing, weird auth schemes, you drop into VCL. You point a Gateway at a ConfigMap containing your VCL, and the operator concatenates it between a generated preamble and a short postamble that runs the deferred routing decisions. If you define sub vcl_recv in your own file, Varnish fuses the bodies and runs them in order at compile time.

One real limitation: user VCL is global. There is no per-route or per-listener VCL injection. If you need listener-specific behavior, you branch on X-Gateway-Listener or X-Gateway-Route, which ghost sets on every request before user VCL runs. This is a Varnish-level constraint and not something we can fully hide. I’d like to remove it eventually.

Running it ourselves

Since v0.16 we’ve been running Varnish Gateway in front of a handful of our own internal applications. Dogfooding is the only way to know what you’ve built and I can honestly say that now, when all the bugs seem have been caught, it has turned into a pleasent system to work with. Spinning up a new instance is quick and easy.

The piece I’m fondest of is the OIDC integration. Several of the internal apps don’t speak any authentication of their own, so I wrote a small VMOD called vmod_oidc and pointed the gateway’s user VCL at it. The gateway now handles the OIDC dance, sets a session, and the backend application sees an already-authenticated request with headers populated with values taken from the OIDC claims.

This was not entirely smooth sailing. On a physical server, getting a custom VMOD onto a Varnish box is unremarkable, you compile it, copy the .so to the right directory, and if something fails you can inspect state and figure out what is wrong. On Kubernetes, with an immutable container image, none of that applies. The way we get vmod_oidc into the gateway today is with an init container that downloads the binary from GitHub Releases at pod startup. It works. It also depends on github.com being reachable every time a pod starts, and GitHub has not been a great citizen on the uptime front lately. As a runtime dependency this is a ticking time bomb.

The fix we’re going to land on is to bake the VMOD into a custom chaperone image — one that ships with vmod_oidc already loaded. More DevOps work to maintain the image, but the pod no longer needs github.com at startup and the failure mode goes away. If you’re planning to use custom VMODs in the gateway in any serious capacity, I’d recommend skipping the init-container shortcut and going straight to a custom image. There are probably other ways to handle custom VMODs that feel properly k8s-native. I’d love to hear those.

What else is in the box

TLS termination on the client and backend sides, with certificates that hot-reload on Secret updates, no pod restart for cert rotation. A multi-listener model that collapses listeners sharing a port into a single Varnish socket, with hostname isolation done by ghost’s vhost router rather than by separate sockets. A built-in dashboard with live event stream, backend health, and per-vhost overview, served from the gateway pod itself. Standard Gateway API status reporting on Gateway and HTTPRoute resources so kubectl get tells you what’s actually accepted and programmed.

Source, issues, and the installation guide live at github.com/varnish/gateway. The docs are at gateway.varnish.org. The quickstart is a helm install and one Gateway resource, and you can have traffic flowing in a few minutes.

I'm at per.buer @ varnish-software.com if you wanna give some feedback. That would be appreciated. In particular, I'd like feedback on the use of the unholy duo - Rust and Go in the same repo. Some of my colleagues have commented that I'll never find somebody who is willing to spend time on a repo with both of these.