Skip to main content

Kubernetes Just Deprecated externalIPs. Here's the Exact Migration Path for Bare Metal

8 min readDora NodaDora Noda
Share
On this page

Kubernetes 1.36 shipped a deprecation notice most cloud-first teams will never notice — and most self-hosted platform operators can't afford to ignore. Service.spec.externalIPs, the decade-old field that lets you bind a Service directly to a node's public IP without a cloud load balancer, is now formally deprecated, with removal on a four-release clock: default-off in 1.40, gate-locked in 1.43, gone in 1.46.

Cloud users are unaffected because they were never supposed to touch this field — it exists for exactly the clusters that don't have a cloud provider wiring up LoadBalancer Services for them. That's every self-hosted PaaS running on owned hardware, including a Cluster API-managed fleet on bare Hetzner boxes. If your ingress or custom-domain story ever leaned on externalIPs as the quick way to get traffic onto a node, here's the timeline, why the field is being removed for cause rather than tidiness, and the concrete commands to move off it before 1.40 flips the default.

The Four-Stage Removal, With Dates That Matter

The deprecation isn't a warning label bolted onto an otherwise-permanent feature — it's a scheduled removal with a published stage plan, tracked as KEP-5707 and detailed in the Kubernetes 1.36 blog post:

VersionWhat changes
1.36 (current).spec.externalIPs is formally deprecated; the AllowServiceExternalIPs feature gate ships, defaulting to true
1.40AllowServiceExternalIPs defaults to false — kube-proxy stops programming rules for the field on any cluster running 1.40 unless the gate is explicitly re-enabled at upgrade time
1.43The feature gate is locked — you can no longer re-enable it
1.46The gate and the DenyServiceExternalIPs admission controller are removed from the codebase entirely

That's roughly two years from "deprecated" to "gone," which sounds comfortable until you count what has to happen on a running fleet in between: every manifest, Helm chart, and Cluster API template that sets externalIPs has to be found, replaced, and verified — not on a Friday before 1.40 ships, but with enough runway to catch anything the audit misses. And because the 1.40 default flip is cluster-wide, not manifest-by-manifest, the failure mode isn't a warning in kubectl apply output — it's every Service still depending on externalIPs silently losing its route the moment the fleet's control plane finishes upgrading, discovered by whichever tenant's app goes unreachable first.

Why This Field Is Being Removed for Cause

The Kubernetes project isn't retiring externalIPs because it's old. It's retiring it because it's been an open security hole since 2020, one the project explicitly declined to patch because a real fix would break the feature's contract:

"The API assumes that every user in the cluster is fully trusted, and in any situation where that is not the case, it enables various security exploits."

That's a direct quote from the project's own reasoning, and the exploit it's referring to is CVE-2020-8554, filed in December 2020 and never patched — only mitigated. The mechanics are almost insultingly simple: Kubernetes doesn't verify that an externalIPs value actually belongs to the cluster or the user setting it. Any principal with permission to create or update a ClusterIP Service can set spec.externalIPs to any address — including one already in use by another Service, another tenant, or a node itself — and kube-proxy will dutifully start routing traffic addressed to that IP into the attacker's pod. No admission check, no ownership validation, no RBAC gate on the field specifically. The Kubernetes Product Security Committee's own conclusion in 2020 was that closing the hole properly would require changing how the feature works, so instead of a patch, they shipped a recommendation: disable the field via the DenyServiceExternalIPs admission controller and don't use it.

That recommendation has existed since Kubernetes 1.21. Deprecation is the project finally forcing the issue five-plus years later.

For a general-purpose Kubernetes user, this is a paragraph in a changelog. For a multi-tenant self-hosted PaaS — where the entire pitch is that unrelated customers' apps run in the same cluster, each with their own namespace and their own ability to create Services — this is the exact threat model the field was flagged for. A tenant who can kubectl apply a Service in their own namespace could, pre-mitigation, hijack traffic meant for a different tenant's app running on the same node IP. If your platform's tenant-facing API ever let a customer's deploy pipeline set arbitrary Service fields without a review layer in front of it, externalIPs was the field that mattered most to have locked down — deprecated or not.

The Migration: Three Concrete Replacements

The Kubernetes blog names three replacements. Only one of them is a real fit for a bare-metal fleet with no cloud provider integration; here's what each actually requires.

Option 1: LoadBalancer Service with a manual status patch (works today, no new components)

This is the fastest like-for-like replacement, and it fixes the RBAC gap that made externalIPs dangerous in the first place — assigning the IP now requires status subresource write access, a permission you can scope separately from ordinary Service create/update:

yaml
apiVersion: v1
kind: Service
metadata:
  name: tenant-app
spec:
  type: LoadBalancer
  loadBalancerClass: none.invalid/manual   # prevents a real LB controller from claiming it
  selector:
    app.kubernetes.io/name: tenant-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
bash
kubectl patch service tenant-app --subresource=status --type=merge \
  -p '{"status":{"loadBalancer":{"ingress":[{"ip":"203.0.113.10"}]}}}'

The loadBalancerClass value is deliberately invalid so no real controller tries to reconcile it — you're using the LoadBalancer type purely to get a .status.loadBalancer.ingress slot, then filling it yourself. Traffic still needs a real path to that IP (a node with it bound, or upstream routing), but the authorization model is now sound: setting .status is a separate, more tightly scoped permission than setting .spec, which closes the exact hole CVE-2020-8554 exploited.

Option 2: NodePort (simplest, but exposes a high port range)

For a quick fix with zero new components, NodePort remains supported and untouched by this deprecation. It doesn't give you a stable, low-numbered IP:port binding — you get a random port in the 30000–32767 range per Service unless you pin one — so it's a stopgap, not a real ingress story for tenant custom domains.

Option 3: MetalLB (the real bare-metal answer)

For a Cluster API-managed Hetzner fleet, this is the actual replacement, not a stopgap. MetalLB implements the LoadBalancer Service type on bare metal the way a cloud provider's controller would — you give it an IP pool, and it handles the announcement:

  • ARP/NDP mode: a node responds to ARP requests for the Service IP on the local L2 segment — the simplest setup, works on a single-datacenter Hetzner rack with no BGP infrastructure.
  • BGP mode: MetalLB peers with your routers and announces routes like a real load balancer, giving you actual multi-node failover instead of one node owning the IP.

Once MetalLB is running, a Service goes back to plain type: LoadBalancer with no manual status patching — MetalLB's controller does the allocation, and because it's a controller with its own RBAC identity (not "any user who can write a Service spec"), the CVE-2020-8554 exploit path doesn't exist. This is the closest thing to a drop-in replacement for what teams were actually using externalIPs to fake.

Option 4: Gateway API (the long-term routing layer)

For teams already migrating off Ingress-NGINX's best-effort maintenance status, Gateway API sits on top of either of the above (a LoadBalancer-backed Gateway, provisioned via MetalLB on bare metal) and is where the project is pointing all new ingress investment. It's the right target for new build-out, not a same-day fix for an externalIPs audit — treat the two migrations as sequential, not the same ticket: get off externalIPs onto MetalLB-backed LoadBalancer Services first, then move the routing layer itself to Gateway API on whatever timeline the Ingress-NGINX retirement already forces.

What to Do Before 1.40

The two-year runway is generous, but the audit is cheap to run today and expensive to skip:

bash
kubectl get svc -A -o json | \
  jq -r '.items[] | select(.spec.externalIPs != null) | "\(.metadata.namespace)/\(.metadata.name)"'

Run that against every cluster in the fleet, not just production — staging and preview environments accumulate the same shortcuts and get audited less. For any hit, also check whether DenyServiceExternalIPs is enabled as an admission controller; if it isn't, enabling it now (rather than waiting for 1.40's default flip) closes the CVE-2020-8554 path immediately instead of on the upstream project's schedule. If the field turns up nowhere, the deprecation costs a fleet nothing but the ten minutes it took to confirm that — worth doing before assuming it doesn't apply.

Bex.co runs tenant custom domains through Gateway API on a Cluster API-managed Hetzner fleet — no externalIPs, no per-Service manual IP hacks, and no cloud load-balancer bill. Star the repo on GitHub or deploy your first app today.

Sources

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex