Skip to main content

Cilium 1.20: Default to Portable Policy, Go Cilium-Native Only Where It Earns It

10 min readDora NodaDora Noda
Share
On this page

For years, writing network policy on a self-hosted Kubernetes fleet meant a quiet tradeoff nobody put in the ADR: every CiliumNetworkPolicy you wrote bought you real features — FQDN egress, L7 rules, cluster-wide scope — and quietly locked your security posture to one CNI. The portable NetworkPolicy API couldn't express a cluster-wide default, couldn't name a hostname, and couldn't say "deny." So teams standardized on the vendor dialect and told themselves the CNI would never change.

Cilium 1.20, announced September 14, 2026, changes the terms of that tradeoff. It implements upstream Kubernetes ClusterNetworkPolicy, promotes its Multicluster Services API support to stable, and keeps pushing the Cilium-native side forward with Google-built datapath plugins and automatic netkit selection. The release is explicitly framed as a choice: portable Kubernetes resources or Cilium-native CRDs, supported side by side. This post makes that choice concrete for a Cluster-API-managed fleet on owned machines — which policy to write in which API, with a worked baseline you can apply today.

The decision, up front

If you stop reading after this table, you have the whole post:

Tenant-isolation needWrite it inWhy
Cluster-wide guardrails no namespace can weaken (default-deny egress, system-namespace lockdown)ClusterNetworkPolicy, Admin tierPortable; survives a CNI swap; enforced above namespaced policy
Cluster-wide defaults namespaces may tighten (allow DNS, baseline egress)ClusterNetworkPolicy, Baseline tierPortable overridable default — the gap BaselineAdminNetworkPolicy filled, now one CRD
Per-app allow rules inside a namespaceStandard NetworkPolicyPortable; every CNI enforces it
Egress to a hostname, not an IP (api.openai.com:443 only)CiliumNetworkPolicy (toFQDNs)Upstream NetworkPolicyPeer cannot name a hostname
L7 rules (HTTP methods/paths, Kafka topics, DNS-aware policy)CiliumNetworkPolicyNo upstream L7 policy API exists
Explicit deny inside a namespace, or world / kube-apiserver entity targetsCiliumNetworkPolicyUpstream policy is additive-only with no entity concept
One Service visible from multiple clustersMCS ServiceExport / ServiceImportPortable across Cilium, Antrea, and other conformant meshes

The rule: default to the portable API, and reach for Cilium-native only where you can point at the feature upstream cannot express. Document the reason on the rule — future you, mid-migration, will want to know which policies are load-bearing on Cilium and which ones move for free.

What Cilium 1.20 actually shipped

Four items matter for this decision. The release announcement leads with Gateway API (v1.6, ExternalAuth, TCPRoute/UDPRoute as Standard) and ENI IPAM for IPv6, but the policy-and-portability story is the quartet below.

Kubernetes ClusterNetworkPolicy (KCNP) support. A standard NetworkPolicy is namespaced and additive: application teams write allows, and nothing in the API lets a cluster admin set a rule that applies everywhere or takes precedence over what a namespace owner wrote. The upstream network-policy-api group spent several iterations on this gap — first AdminNetworkPolicy plus BaselineAdminNetworkPolicy, then, in the v0.2.0 release of April 21, 2026, a single ClusterNetworkPolicy (v1alpha2) with a tier field merging both. Cilium 1.20 implements it: cluster-scoped, tiered, with an Admin tier that takes precedence over namespaced NetworkPolicy and a Baseline tier that acts as a default namespaced policy can override. Google's GKE team built the implementation on open-source Cilium, backported it to Cilium 1.19 for GKE, and upstreamed it so the broader ecosystem gets it starting with 1.20.

MCS-API promoted to stable in ClusterMesh. Cluster Mesh has always been the capable-but-coupled option: multi-cluster networking that only speaks Cilium. The SIG Multicluster working group's Multi-Cluster Services API (multicluster.x-k8s.io) is the portable counterpart — ServiceExport in each cluster makes a Service discoverable across the set, with ServiceImport and *.svc.clusterset.local DNS doing the rest. Cilium carried MCS-API as beta for several releases; 1.20 promotes it to stable support, with MCS conformance results published against 1.20.0.

Datapath plugins (beta), developed by Google. Third-party code can now instrument Cilium's eBPF datapath as its own plugin: a separate process and a separate image, built and rolled out on its own cadence, where a plugin crash doesn't take the agent down. No fork, no patch queue against upstream. The announcement frames it as Cilium becoming "less like a sealed networking appliance and more like a network operating system" — a stable core that providers extend independently of the release cycle.

Automatic datapath mode selection (netkit auto). Netkit replaces the veth pair for pod networking at host-level throughput — Meta has rolled it out across millions of containers, and ByteDance reports roughly 10% improvement in its tests — but it needs kernel 6.8 or newer, which split mixed-kernel fleets into per-pool configuration. bpf.datapathMode=auto ends that: each agent probes its host at startup, takes netkit where the kernel supports it, and quietly falls back to veth elsewhere. One setting across the fleet, with per-node reality visible via cilium-dbg status and the cilium_feature_datapath_config metric.

The portable baseline, worked end to end

Here is the smallest cluster-wide baseline worth writing in the portable API: default-deny all egress at the Admin tier, allow DNS at the Baseline tier so namespaces inherit working name resolution, and let each namespace add its own allows on top.

First, the two enablement steps. Install the upstream CRD (v0.2.0 is the release carrying ClusterNetworkPolicy):

bash
kubectl apply -f https://raw.githubusercontent.com/kubernetes-sigs/network-policy-api/refs/tags/v0.2.0/config/crd/standard/policy.networking.k8s.io_clusternetworkpolicies.yaml

Then enable the alpha implementation in Cilium:

bash
helm upgrade cilium cilium/cilium --version 1.20.0 \
  --namespace kube-system --reuse-values \
  --set k8sClusterNetworkPolicy.enabled=true

Now the policies. The Admin-tier rule is the floor no namespace can punch through:

yaml
apiVersion: policy.networking.k8s.io/v1alpha2
kind: ClusterNetworkPolicy
metadata:
  name: default-deny-egress
spec:
  tier: Admin
  priority: 100
  subject:
    namespaces:
      matchLabels:
        policy.company.example/tenant: "true"
  egress:
    - name: deny-all-by-default
      action: Deny
      to:
        - namespaces: {}

And the Baseline-tier rule is the default every tenant inherits but any namespace policy can refine — DNS must work before anything else can:

yaml
apiVersion: policy.networking.k8s.io/v1alpha2
kind: ClusterNetworkPolicy
metadata:
  name: baseline-allow-dns
spec:
  tier: Baseline
  priority: 100
  subject:
    namespaces:
      matchLabels:
        policy.company.example/tenant: "true"
  egress:
    - name: allow-dns
      action: Allow
      to:
        - namespaces:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          pods:
            matchLabels:
              k8s-app: kube-dns
      ports:
        - portNumber: 53
          protocol: UDP

Evaluation order is the point of the design: Admin-tier rules evaluate first and take precedence, then namespaced NetworkPolicy, then Baseline-tier rules as the fallback default. A tenant namespace adds a standard NetworkPolicy allowing egress to its database; the Admin deny still holds for everything else, and the Baseline DNS allow means the namespace author never has to remember port 53. Nothing in these three objects mentions Cilium. Move the fleet to Calico — which enforces the same v1alpha2 API as of v3.32 — or Antrea, and the baseline moves with it.

One honest caveat: this is still an alpha API (v1alpha2), enabled behind a Cilium feature flag, with the CRD installed out-of-band rather than shipped by Kubernetes itself. "Portable" here means portable across CNIs that implement the same draft, not GA stability. That is still a strictly better lock-in story than a dialect only one CNI speaks — but pin the CRD version in Git and treat the API bump to beta as a tracked upgrade item, not background noise.

What upstream still cannot express

Portability has a boundary, and tenant isolation keeps hitting four things on the far side of it. Each one is a real rule a multi-tenant platform writes in its first year, and each one currently needs the Cilium-native CRD:

FQDN egress. "This tenant's workloads may reach api.openai.com:443 and nothing else on the internet" is the single most-asked egress rule on a shared platform, and a NetworkPolicyPeer cannot name a hostname — selectors cover pods, namespaces, and CIDR blocks only. CiliumNetworkPolicy's toFQDNs with its DNS-aware proxy is the answer, and there is no portable equivalent on any roadmap this year.

L7 policy. Method-and-path HTTP rules, Kafka topic restrictions, DNS-request-aware policy — anything above L4 — lives entirely in Cilium-native rules (or a service mesh). Upstream policy stops at ports and protocols.

Explicit deny and entity targets. Standard NetworkPolicy is additive: you can only allow, never deny, and you can only select pods, namespaces, and IP blocks. There is no world entity for "the internet but not the cluster," no kube-apiserver entity for the API server's shifting endpoint. Cilium-native policy has both denies and the entity vocabulary.

Node-level and host-firewall scope. Rules that protect the node itself rather than pod traffic remain Cilium-clusterwide-native (CiliumClusterwideNetworkPolicy) with no upstream counterpart.

The practical posture: keep these rules in a clearly-labeled directory — policy/cilium-native/ next to policy/portable/ — with a one-line comment on each file naming the missing upstream feature. When someone asks "what breaks if we switch CNIs," the answer is ls plus a reading of comments, not an archaeology project. And revisit the directory at every Cilium minor release: the portable surface keeps growing (this release alone moved cluster-wide policy and multi-cluster services across the line), so a rule that needed the native dialect in 1.19 may not need it in 1.21.

MCS goes stable: portable multi-cluster services

The same portability logic applies one level up. A fleet that starts as one Cluster API-managed cluster on a handful of Hetzner machines rarely stays one cluster: regions split, a GPU pool becomes its own cluster, staging stops sharing a control plane with production. The day that happens, every service that assumed single-cluster DNS needs a multi-cluster answer.

The MCS answer is one resource the application team manages — a ServiceExport with the same name and namespace as the Service, created in each participating cluster:

yaml
apiVersion: multicluster.x-k8s.io/v1alpha1
kind: ServiceExport
metadata:
  name: web
  namespace: default

The mesh populates ServiceImport and serves web.default.svc.clusterset.local — a ClusterIP for the merged endpoints, or per-cluster DNS records for headless services. With Cilium 1.20's stable support and published conformance, this is no longer the experimental path; it is the boring path, and boring is what you want for cross-cluster service discovery. The alternative — ClusterMesh-only annotations and Cilium-specific service sync — works, but it answers "what breaks if we switch CNIs" with "the multi-cluster story, all of it."

The Hetzner-fleet checklist

Concretely, for a small Cluster API fleet on owned hardware adopting this posture at 1.20:

  1. Install the network-policy-api v0.2.0 CRD before upgrading Cilium, pinned in Git next to the Cilium release manifest. The API is alpha; the pin is what makes it operable.
  2. Enable k8sClusterNetworkPolicy.enabled and write the Admin default-deny plus Baseline DNS allow first, before migrating any existing CiliumClusterwideNetworkPolicy baselines. Run both during the transition — a Pass-action trial policy structurally cannot weaken an existing default-deny.
  3. Migrate ANP/BANP now if you carry them. The two-CRD v1alpha1 API is deprecated upstream; Calico v3.32 already refuses to enforce it. The migration is mechanical: one ClusterNetworkPolicy with tier: Admin or tier: Baseline per old object.
  4. Audit kernels for netkit before setting auto. Anything below 6.8 silently lands on veth — fine, but check cilium_feature_datapath_config so "auto" doesn't mean "unknown."
  5. Prefer ServiceExport for anything that might ever span clusters, even while the fleet is still one cluster. The cost of the portable form today is one YAML file; the cost of converting later is a migration.
  6. Track datapath plugins as a future extension point, not a day-one dependency. Beta, provider-oriented, and most valuable once you have bespoke observability or security logic that today would mean forking the CNI — exactly the trap this whole posture is trying to avoid.

None of this is anti-Cilium. Cilium remains the enforcement engine doing the actual work — eBPF, Hubble visibility, the FQDN proxy your egress rules depend on. The argument is narrower: the intent layer, the YAML that says what your tenants may reach, should live in APIs more than one project implements wherever those APIs suffice. Cilium 1.20 is the release where "wherever those APIs suffice" finally includes cluster-wide policy and multi-cluster services. Write the portable baseline now, quarantine the native exceptions with labels, and the next CNI decision — whenever it comes — starts from an inventory instead of a rewrite.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

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