Per-Gateway Isolation¶
By default every Gateway of the class shares one chart-deployed proxy pool and one Cloudflare Tunnel. This guide covers the opt-in hard-isolation mode: a DEDICATED data plane — its own proxy Deployment and its own tunnel — rendered and reconciled by the controller for a single Gateway.
Use it when admission-level scoping (see Multi-Tenancy) is not enough: tenants that must not share a process, a tunnel identity, or a failure domain.
What you get¶
- A dedicated proxy Deployment in the Gateway's namespace, running the same proxy image as the shared plane (chart-wired via
--proxy-image, overridable per Gateway). - A dedicated Cloudflare Tunnel: the Gateway's routes are written to ITS tunnel's ingress document and pushed to ITS proxy pods only. Routes of other Gateways never reach this data plane — and this Gateway's routes never reach theirs.
- Independent lifecycle: rendered resources are controller-owned (deleted with the Gateway, healed on drift), connector draining on shutdown, optional autoscaling on the proxy's in-flight gauge.
- A per-Gateway config-push credential: the controller authenticates config pushes to this plane with the Gateway's own token, never the shared plane's.
Opting in¶
-
Create a Cloudflare Tunnel for the Gateway (one tunnel per isolated Gateway) and store its connector token in the Gateway's namespace:
-
Create a
GatewayConfignext to the Gateway: -
Reference it from the Gateway:
The controller renders cf-proxy-edge (Deployment) and cf-proxy-edge-config (headless Service) in tenant-a, parses the tunnel identity from the connector token (there is deliberately no separate tunnelID field — it cannot drift from the token), checks with Cloudflare that the token really holds that tunnel (see Proving a tunnel claim), and starts syncing the Gateway's routes to that tunnel. The Gateway's status address becomes <tunnel-id>.cfargotunnel.com, and Programmed turns True only once the rendered Deployment has ready replicas — that is, registered tunnel connectors.
A Gateway without infrastructure.parametersRef keeps the shared data plane, unchanged. Removing the ref later deletes the rendered resources (only when actually owned by the Gateway) and returns the Gateway to the shared plane.
GatewayConfig reference¶
| Field | Required | Meaning |
|---|---|---|
tunnelTokenSecretRef | yes | Connector-token Secret in the SAME namespace (key tunnel-token by default). Token rotation rolls the proxy pods automatically. |
cloudflareCredentialsSecretRef | no | API-token override for this Gateway's tunnel-document writes and for checking its tunnel claim, from a Secret in the SAME namespace (key api-token by default); defaults to the GatewayClass → GatewayClassConfig credentials. |
authTokenSecretRef | no | Bearer token (key auth-token) protecting this plane's config API; the controller pushes with it. |
replicas | no | Fixed replica count; default 2 (the HA floor — one connector pod is a tunnel availability hazard), max 100. Mutually exclusive with autoscaling. |
autoscaling | no | Renders a HorizontalPodAutoscaler — see below. minReplicas/maxReplicas cap at 100. |
resources | no | Proxy container resources; chart-parity defaults when unset. |
image | no | Proxy image override; defaults to the release's proxy image. |
All Secret references are namespace-local by construction, so a Gateway cannot point at another tenant's credentials. That is narrower than it sounds: a tenant does not need another tenant's Secret to reach their tunnel, because the tunnel identity is parsed from the connector token and a token is just base64 JSON. Writing someone else's tunnel UUID into your own token is the same claim without touching their Secret, which is why the controller checks every claim with Cloudflare and arbitrates tunnel ownership separately (see below).
Gateway.spec.infrastructure.labels and .annotations propagate to the rendered resources and the pod template; changing them rolls the pods. The Secrets the controller generates for the plane (the config API auth token and the config API TLS certificates) get them when they are created; the controller never rewrites those Secrets, so a later change reaches a TLS Secret at its next issuance and the auth-token Secret only if it is deleted and regenerated.
Autoscaling¶
The rendered autoscaling/v2 HPA scales the proxy Deployment on cftunnel_proxy_requests_in_flight as a Pods-type custom metric — concurrency is the saturation signal for an I/O-bound L7 hop, not CPU. Serving Pods metrics to the HPA requires a metrics adapter (prometheus-adapter or KEDA) exposing the gauge through the custom-metrics API; without one the HPA reports FailedGetPodsMetric and holds minReplicas — visible degradation, never silent. See Metrics & Alerting for adapter examples.
Do not pair a VerticalPodAutoscaler in apply mode with these Deployments: applying VPA recommendations restarts pods, which drops tunnel connectors. Recommendation mode is fine.
Proving a tunnel claim¶
The controller does not take a connector token's word for the tunnel it names. Before a dedicated Gateway is accepted, the controller fetches that tunnel's own connector token from the Cloudflare API and checks that the Gateway's token carries the same account, tunnel and secret. Anyone can write a tunnel UUID into a token; only someone who was given the tunnel's token has its secret. A token whose account tag is not a Cloudflare account ID is refused as an invalid parameter before anything is asked.
The request uses the credential that will write the tunnel's configuration: the GatewayConfig's cloudflareCredentialsSecretRef when it names one, the GatewayClass credentials otherwise. It needs no permission beyond the one the controller already requires. The Cloudflare API lists the same three accepted permissions, Cloudflare Tunnel Write among them, for reading a tunnel's token and for writing its configuration.
A claim that does not check out is refused the way a contested one is: Accepted=False with reason InvalidParameters, a TunnelClaimRejected Warning Event, no routes programmed, and no data plane rendered. The message says Cloudflare did not confirm the claim and stops there. It does not say whether the tunnel is missing or the secret differs, because telling those apart would tell a tenant which tunnel IDs exist in the account; the controller log has the detail. An unconfirmed claim holds nothing and contests nothing, so a token that never proves out cannot keep a tunnel from the Gateway whose token does.
A credential that Cloudflare rejects with 401 or 403 decides nothing. Those answers describe the API credential rather than the claim, and a revoked or mis-scoped token cannot be told apart from one for another account, so refusing on them would take down every dedicated plane that uses the credential. The claim is treated as unchecked instead, as during an outage (below): a new claim waits, and a Gateway already serving its tunnel keeps it unless another namespace's claim on that tunnel is confirmed. Writes to the tunnel with that credential fail as well, and the routes report those failures.
Verdicts are kept in the controller's memory under a digest of the token and the credential, never either one itself. A confirmation is reused for an hour, and each dedicated Gateway is reconciled at least every half hour, so a rotated tunnel secret is noticed within about an hour and a half: the Gateway turns Accepted=False and its data plane is removed. That change to its Accepted condition also starts a route sync, which drops its routes from the tunnel's document. A refusal is reused for five minutes, so a fix made on the Cloudflare side takes effect within that. A replaced token or credential is a new claim and is checked on the next reconcile.
When the Cloudflare API cannot be reached, nothing is refuted and nothing new is confirmed:
- A new claim is refused, with a message saying the check will be retried. Cloudflare is asked again at most every half minute per claim, so an outage does not multiply the calls to an API that is already failing. A Gateway created during an outage stays
Accepted=Falseuntil the API answers. - A claim confirmed earlier keeps that confirmation for as long as the outage lasts. Only an answer from Cloudflare can take it away.
- A Gateway already advertising its tunnel in
status.addresseskeeps the tunnel unverified, even when a controller restart has emptied the cache, as long as no other namespace's claim on that tunnel is confirmed. Its address is taken as the claim until the API answers. That rests on Gateway status alone, so it inherits the status-write gap described under What this does not settle.
A confirmed claim always outranks an unchecked one, outage or not. A tenant can make its own claim unchecked whenever it likes, by breaking the credential its GatewayConfig names or deleting its own token, and a refused Gateway keeps its address; if possession carried an unchecked claim past a confirmed one, a refuted squatter could take its tunnel back that way.
Sharing a tunnel¶
A Cloudflare Tunnel belongs to the Gateway already serving it, and the GatewayClass tunnel belongs to the operator. A Gateway whose connector token names a tunnel that another namespace's Gateway — or the GatewayClass itself — already serves is refused: it reports Accepted=False with reason InvalidParameters, emits a TunnelClaimRejected Warning Event, none of its routes are programmed anywhere, and its data plane is not rendered at all. Its routes say so too, so the tenant can see the cause without asking the operator.
The plane is removed rather than merely left unconfigured. That matters when both parties hold the SAME token — an operator handing one token to two Gateways — because both connectors then register and Cloudflare load-balances across them, so a configless survivor would answer a share of the incumbent's requests with 404s.
A forged claim, a neighbour's UUID written into a token the tenant minted themselves, never gets this far: Cloudflare does not confirm it, so it is refused as unproven (see Proving a tunnel claim). It could not connect to that tunnel either, since connector registration authenticates on the account tag and the tunnel secret inside the token, not on the tunnel UUID. What such a claim bought before either check was the controller pushing that tunnel's merged configuration — hostnames, backends, and any backend-mTLS client keys — straight to the claimant's proxy pods over the config API, which needs no working connector.
Among claims equally proven, possession, not age, decides who holds a contested tunnel: a Gateway already advertising that tunnel in its status addresses keeps it against any such newcomer, however old the newcomer is. A confirmed claim still outranks an unchecked holder, as described under Proving a tunnel claim. Otherwise a tenant whose Gateway happens to predate the victim's could retarget its own token and evict the legitimate holder. Age (then UID) decides only between claims with equal standing, such as two first-time claims.
The refusal exists for a token that is genuine but held in more than one namespace: one tunnel's token handed to two tenants, or copied out of the namespace it was issued to. Holding a tunnel's token proves access to the tunnel, not a right to the routes another namespace serves on it. Without arbitration, such a token is enough to join the neighbour's partition — and because Cloudflare load-balances a tunnel's requests across every connector, the controller then merges both parties' routes into one ingress document and pushes the union to both parties' proxies. The claimant receives the incumbent's hostnames, backend services, filter bodies, and any backend-mTLS client certificates their routes carry.
Two Gateways in the SAME namespace may share a tunnel: that is a tenant sharing with itself, and no boundary is crossed.
A Gateway being deleted keeps its tunnel until the object is gone. Its data plane is owned by the Gateway and removed by garbage collection rather than by this controller, so while a finalizer holds the Gateway, its connector can still be registered on the tunnel. Releasing the tunnel earlier would let another namespace's claim be accepted while that connector still serves.
If every party on a tunnel is trusted to see the others' routes — a single-tenant cluster, or a migration from the shared plane to dedicated ones — an operator can set allowSharedTunnels: true on the cluster-scoped GatewayClassConfig (chart value gatewayClassConfig.allowSharedTunnels). That restores the merge behaviour for the whole class. The field is deliberately not on GatewayConfig: a tenant must not be able to grant it to themselves.
When two DEDICATED Gateways end up on one tunnel, every affected route carries a cf.k8s.lex.la/TunnelShared=True condition and a mirrored TunnelShared Warning Event naming the other Gateways, so the collapsed isolation stays visible rather than silent. That covers both ways it can happen — the operator enabling allowSharedTunnels, and two Gateways in one namespace, which needs no opt-in. The condition's reason tells them apart: TunnelSharedAcrossNamespaces when any Gateway on the tunnel lives in another namespace, TunnelSharedWithinNamespace when they all share the route's Gateway's namespace.
A dedicated Gateway sharing the CLASS tunnel is not flagged: the collision detector counts dedicated partitions only, and the shared plane is not one. Watch the controller log for that case.
What this does not settle¶
A confirmed claim proves the tenant holds the tunnel's token, not that anyone meant them to. Among Gateways whose tokens all check out, ownership is still first-claim-wins, so a tenant given a copy of a tunnel's token can take that tunnel if they claim it before its intended owner does. Treat connector tokens as the credentials they are.
The same holds outside this cluster. Arbitration can only contest a tunnel some Gateway represents, so a genuine token for any OTHER tunnel in the same Cloudflare account — another cluster's, another product's, a bare cloudflared — is uncontested, and a GatewayConfig without its own cloudflareCredentialsSecretRef writes with the credentials resolved from the GatewayClass. The controller then overwrites that tunnel's ingress document with the claiming tenant's routes, using the operator's API token. The check means the tenant must hold that tunnel's token, not just know its UUID; beyond that, give tenants who need isolation their own Cloudflare account rather than their own tunnel.
A claim that has not yet advertised anything is not defended while its configuration is unreadable. An established holder survives a token rotation because its address carries the claim — unless another namespace holds the same tunnel's token and its claim is confirmed during that window — but a Gateway still bootstrapping — no address written yet — drops out of the arbitration entirely if its GatewayConfig or its token Secret cannot be read at that moment. If only the API credential its claim is checked with cannot be read, the claim stays in the arbitration but is refused as unproven, and an unproven claim contests nothing. Either way, a competing claim on the same tunnel is then accepted, provided Cloudflare confirms it. The alternative, refusing to arbitrate at all on any unreadable claim, would let any tenant freeze the rule cluster-wide by deleting their own Secret.
Removing a refused Gateway's data plane relies on the controller ownerReference it stamped on the proxy Deployment: the cleanup deliberately leaves alone any object whose reference has been stripped, so it never deletes something another controller now owns. A tenant with write access in their own namespace can therefore strip that reference and keep the pod, and so the connector, running. It gets no route config, so it serves nothing of its own — but on a SHARED token it stays a registered connector on the contested tunnel. That grants the tenant nothing new: the token comes from a Secret in their own namespace, so they could run the same connector without this Deployment, and the removal is hygiene rather than a boundary. The generated cf-proxy-<gateway>-auth Secret also outlives the plane until the Gateway is deleted, because the controller may create Secrets but not delete them; it only authenticates pushes to a config API that no longer exists.
Possession is read from Gateway.status.addresses, so anyone able to write gateways/status in their own namespace and holding the tunnel's token can forge it and compete as a holder; between two holders the older Gateway wins, so an older forger evicts the real one at once. While the Cloudflare API cannot be reached, a forged address can hold a tunnel even without its token, against any claim the controller has not confirmed since it last started, until the API answers and refutes it. The Gateway API CRDs carry no RBAC aggregation labels, so the built-in edit and admin roles do not grant it; if you grant status write to tenants, this rule does not hold for them.
Watch for it in the TunnelClaimRejected Warning Events: a Gateway you believe owns a tunnel reporting Accepted=False against a claimant you do not recognise means another namespace holds that tunnel's token, or has written its status. The remedy is operator-side: delete the squatting Gateway, or remove its spec.infrastructure.parametersRef. The rightful owner's status recovers on its own reconcile, within about half a minute. Nothing wakes it sooner: tunnel squatting is cross-namespace by definition, and the sibling-Gateway watch that makes a data-plane cap recover promptly lists only the event object's own namespace. From that reconcile on, no route change is needed — re-rendering the plane runs a full route sync immediately. That sync runs before the new proxy pods exist, so its push fails, but the controller keeps the config it built and pushes it when the pods appear in the EndpointSlice, every ten seconds until one takes it. Deleting only the GatewayConfig does NOT release the claim — a Gateway that already advertises a tunnel keeps holding it even when its configuration no longer resolves, which is what stops a token rotation from surrendering a tunnel.
Capping data planes per namespace¶
Every opted-in Gateway renders a proxy Deployment, a headless Service, a NetworkPolicy and an optional HPA into its namespace, and registers a connector on its tunnel. Nothing about the opt-in bounds how many a tenant may ask for: a namespace with create on Gateway and GatewayConfig can multiply that as far as its object quota allows.
maxDataPlanesPerNamespace on the cluster-scoped GatewayClassConfig (chart value gatewayClassConfig.maxDataPlanesPerNamespace) caps it. Leave it unset for no cap, so nothing changes until an operator sets one. 0 is rejected by the apiserver rather than accepted as another spelling of unlimited: it is what an operator writes for "no dedicated planes at all", and a field that granted the opposite would fail open. Like allowSharedTunnels, the field is deliberately not on GatewayConfig — a tenant must not be able to raise their own cap. On a cluster upgraded from a release before the field existed, re-apply the GatewayClassConfig CRD first: Helm never upgrades CRDs, and the apiserver prunes a field the installed schema does not declare, so the cap would read back unset and enforce nothing. See CRD upgrades.
apiVersion: cf.k8s.lex.la/v1alpha1
kind: GatewayClassConfig
metadata:
name: cloudflare-tunnel-config
spec:
tunnelID: "550e8400-e29b-41d4-a716-446655440000"
cloudflareCredentialsSecretRef:
name: cloudflare-credentials
maxDataPlanesPerNamespace: 3
Past the cap, a Gateway reports Accepted=False with reason DataPlaneQuotaExceeded, emits a DataPlaneQuotaExceeded Warning Event, gets no data plane rendered, and has none of its routes programmed — its routes say the cap was the reason, so the tenant can see the cause without asking the operator. DataPlaneQuotaExceeded is an implementation-specific reason; the Gateway API allows controllers to raise Accepted with reasons outside its own list, and none of the listed ones describes a Gateway refused for capacity rather than for something wrong with its spec.
Which Gateways keep their planes is decided oldest-first by creation timestamp, breaking equal timestamps by UID the same way tunnel arbitration does. A Gateway created now is the newest in its namespace, so it takes a free slot or is refused, and never evicts one already serving. An OLDER Gateway that opts in later does evict, because the order is by creation timestamp and not by when the plane was asked for: the displaced Gateway is refused and its plane removed on the reconcile its sibling's opt-in triggers. Beyond that the verdict does not move between reconciles. Lowering the cap below what a namespace already holds does evict: the newest planes above the new limit are torn down on the next reconcile, so lower it deliberately.
Freeing a slot is not instant, but nothing has to be poked to start it. Deleting a sibling Gateway wakes both controllers on that event, so the refused Gateway's status flips and its plane is rendered without waiting for the refusal's half-minute requeue, and rendering a plane runs a full route sync immediately. What remains is pod startup: that sync runs before the new proxy pods exist, so its push fails, and the controller pushes the config it built when the pods appear in the EndpointSlice, every ten seconds until one takes it. Expect the gap between Accepted=True and traffic flowing to be roughly how long the pods take to start, plus up to ten seconds.
A refused Gateway keeps the tunnel CNAME already published in its status.addresses, deliberately: an address is how this controller records possession of a tunnel, and clearing it on a refusal would surrender the tunnel to any other namespace claiming it. The practical consequence after a cap lowering is that external-dns keeps the DNS record and the hostname answers HTTP 530 (no connector) rather than NXDOMAIN. Delete the Gateway to retire the record.
Only Gateways carrying spec.infrastructure.parametersRef count. The shared data plane serves any number of Gateways and is unaffected, which makes dropping the parametersRef the tenant-side remedy when a namespace has more Gateways than dedicated planes it can have.
A Gateway refused for claiming a tunnel it does not own also holds its slot, even though it gets no plane. The two rules are deliberately independent, so neither verdict moves when the other changes; the wasted slot is the refused tenant's own.
A Gateway being deleted holds its slot until it is gone. The controller adds no finalizer of its own, so the window is normally milliseconds — a Gateway held back by someone else's finalizer keeps its slot for as long as that lasts. The alternative under-counts planes whose pods are still running.
A Gateway counts even when its configuration does not currently resolve — a missing GatewayConfig, an unreadable connector-token Secret. It is asking for a plane either way, and counting only the ones that resolve would let a tenant free a slot by making one of their tokens unreadable.
The cap bounds how many dedicated planes one namespace may HOLD, not what they consume and not how many namespaces exist. A namespace capped at one plane still sets that plane's replicas or autoscaling.maxReplicas itself, and a tenant who can create namespaces can multiply planes by spreading them. Pair the cap with a ResourceQuota and restricted namespace creation where either matters.
Securing a tenant data plane¶
A tenant data plane is locked down by default on three axes:
- The config API is authenticated. When a GatewayConfig declares no
authTokenSecretRef, the controller generates a random bearer-token Secret (cf-proxy-<gateway>-auth, controller-owned, never rotated) and wires the proxy to it, so a tenant plane is never exposed unauthenticated.authTokenSecretRefis a bring-your-own-token OVERRIDE for operators who want to manage the token themselves (external secret stores, scheduled rotation). The proxy reads the token from aSecretKeyRefenv var at start, so the pod template hashes the resolved token: rotating the bring-your-own Secret rolls the proxy pods automatically (same astunnelTokenSecretRef), and the controller re-authenticates its config pushes with the new token. - The config API is network-restricted. The controller renders a NetworkPolicy per data plane that admits the config API port (
proxy.configAPIPort, 8081 by default) only from the controller pod (its labels AND'd with its namespace, not the tenant's), so neither the config API nor the/metricsit co-serves is reachable from arbitrary pods — including other pods in the controller's own namespace, and including any in the Gateway's. The proxy's data port (8080) takes no in-cluster ingress at all — traffic arrives through the outbound tunnel. Because/metricsshares the locked port, a policy that admits only the controller silently breaks Prometheus scraping and therefore the rendered HPA (it reportsFailedGetPodsMetricand holdsminReplicas); setproxy.networkPolicy.monitoringNamespaceSelectorin the chart to additionally admit your monitoring namespace. Where the CNI does not enforce NetworkPolicy this layer is a no-op (defense in depth, not a guarantee). - The config API is encrypted. With
proxy.configAPITLS.enabled(the default) each plane serves its config API over TLS with a certificate for its own config Service, issued by the controller from a CA that stays in the controller's namespace, in a Secretcf-proxy-<gateway>-config-tls-<n>owned by the Gateway (for some Gateway names, such as a long one or one with a dot, the part before-<n>is shortened and ends in a hash; the slot suffix is always kept). The controller pushes only to a plane presenting that certificate, so a certificate the tenant writes into the Secret is refused and replaced with the next slot, which rolls the plane (reported asConfigTLSCertificateReplacedon the Gateway). Renewal moves the plane to the next slot the same way. Superseded slots stay until the Gateway is deleted, because the controller may create Secrets there but not update or delete them. AGatewayConfig.spec.imagepinned to a proxy image that predates config API TLS ignores the mounted certificate and serves plain HTTP, so its HTTPS probes fail and the plane never becomes ready; move it to a current image.
Authentication and the NetworkPolicy bound reach; TLS provides confidentiality for the bearer token and, where the Gateway configures backend mTLS, the client certificate's private key. With proxy.configAPITLS.enabled: false the push is plain HTTP and an on-path party inside the cluster reads both on a CNI without pod-to-pod encryption — see Config API TLS.
Also note the RBAC equivalence: create on GatewayConfig (plus a Gateway referencing it) lets a user run an arbitrary image via spec.image under the namespace's default ServiceAccount — see the security reference.
Operational notes¶
- Events: the controller emits
ProxyProvisioned(Normal) on the Gateway when the data plane is rendered, andRenderFailed(Warning) when rendering cannot proceed (apply failures) —kubectl describe gatewayshows both. - No proxy image configured: if neither
GatewayConfig.spec.imagenor the controller's--proxy-imagedefault is set, the data plane cannot be rendered. The Gateway surfacesAccepted=Falsewith reasonInvalidParametersand a message naming the missing image (a persistent condition, not just a transient Event) — set one of the two and the Gateway recovers on the next reconcile. - Namespace at its data-plane cap: the Gateway surfaces
Accepted=Falsewith reasonDataPlaneQuotaExceededand aDataPlaneQuotaExceededWarning Event, nothing is rendered for it, and its routes are programmed nowhere. Delete one of the namespace's other dedicated Gateways, drop this one'sspec.infrastructure.parametersRefto serve it from the shared plane, or ask the operator to raisemaxDataPlanesPerNamespace. See Capping data planes per namespace. - Image older than the controller: a
spec.imagepinned behind the controller can receive a filter type it does not know. That proxy answers HTTP 500 (gRPC:UNAVAILABLE) for the requests the filter would have processed (the whole rule for a rule-level filter, that backend's share for a backend-level one) and logs an error naming the first such rule by index and hostnames plus a warning with the number of affected rules, while the rest of the pushed config applies normally. Move the image forward to serve the rule. - Drain: on pod shutdown the proxy unregisters its connectors from the edge and gives in-flight requests a grace period before exiting; the rendered
terminationGracePeriodSecondscovers the window. - RBAC: rendering requires cluster-wide write on Deployments/Services/HPAs (Gateways live in arbitrary namespaces); see the security reference for the exact rules and ownership guards.
- Failure containment: a failed write of one Gateway's tunnel ingress document changes no route status, since the document only feeds the Cloudflare dashboard; the
TunnelDocumentWriteFailedWarning Event lands only on the Gateways that tunnel serves. - A route on several tunnels: a route attached to Gateways whose data planes use different tunnels is in each of those planes' configs, and each plane serves it only on the hostnames that the Gateways on its own tunnel give it. A hostname that only a Gateway on another tunnel gives the route is not answered here, even while this plane cannot read one of its own Gateways.
- Post-render breakage: if the GatewayConfig stops resolving AFTER a healthy render (token Secret deleted, ref broken), new route changes fail closed — they are not programmed anywhere — but the already-running data plane keeps serving its LAST pushed config until the configuration resolves again or the Gateway is deleted. The Gateway surfaces
InvalidParameters, and a route bound only to the broken Gateway reportsAccepted=Falseon that parent (it is served nowhere) — a route accepted on a healthy parent too keepsAccepted=Truethere. - Not rendered: per-Gateway PodDisruptionBudget and ServiceMonitor. The shared plane's ServiceMonitor does not select rendered pods; scrape them with a PodMonitor on the
cf.k8s.lex.la/gatewaylabel if needed (and admit the monitoring namespace viaproxy.networkPolicy.monitoringNamespaceSelector, since the rendered NetworkPolicy locks the metrics port). Withproxy.configAPITLS.enabled(the default) that port serves HTTPS, so the PodMonitor endpoint needsscheme: httpsandtlsConfig.insecureSkipVerify: true. - Cloudflare-side cleanup on teardown: when no Gateway serves a tunnel any more — the Gateway was deleted, dropped
infrastructure.parametersRef, was refused, or had its token switched to another tunnel — the next route sync writes that tunnel an ingress document holding only the catch-all rule, using the credential it last used for that tunnel. The tunnel itself is not deleted, and the GatewayClass tunnel is never emptied: it belongs to the operator, so changing the classtunnelIDleaves the old tunnel's document serving until the shared proxy moves to the new token. The controller remembers which tunnels it wrote only while it runs, so a tunnel given up while the controller is down keeps its last document; so does one whose write Cloudflare rejects outright (401, 403, or another 4xx except 429), such as a tunnel already deleted. A Gateway whose configuration merely stops resolving keeps its document, like its data plane. - Sustained config-push failure is surfaced: if the controller cannot push a route's config to its data plane for a sustained run of attempts (the proxy pods are unhealthy or the config-API NetworkPolicy blocks the push), the affected routes carry a
cf.k8s.lex.la/ProxyConfigPushed=Falsecondition and aProxyConfigPushFailedWarning Event — the route spec is valid, but the in-cluster proxy never received the config, so matching requests 502.Accepted/Programmedare unchanged (the failure does not flap them on a one-off blip), and the condition clears automatically on the first successful push. - API-credential rotation is watched: rotating the
cloudflareCredentialsSecretRefoverride (the controller-side API token, distinct from the connector token) re-syncs the Gateway's routes promptly — the controller watches that Secret. Connector-token (tunnelTokenSecretRef) rotation also re-renders the proxy Deployment. - Well-known labels: every rendered resource (Deployment, headless Service, HPA, NetworkPolicy) and every Secret the controller generates for the plane carries the GEP-1762 well-known labels
gateway.networking.k8s.io/gateway-nameandgateway.networking.k8s.io/gateway-class-nameon its metadata (never the immutable selector), so ecosystem tooling that understands the Gateway API convention can discover a Gateway's dedicated data plane without a controller-specific label. Both values go through the same truncation as thecf.k8s.lex.la/gatewayselector label: Gateway and GatewayClass names are DNS-1123 subdomains up to 253 characters, but Kubernetes label values cap at 63, so tooling matching on the exact Gateway or GatewayClass name will miss for names longer than that.