Skip to main content

Control pod egress (and the FQDN limitation)

Inspect 1.36

Egress control limits what your pods can call out to, which is how you contain data exfiltration and a compromised pod's reach. On Syself Autopilot you write egress with Cilium policy at Layers 3 and 4: address, port, and workload identity. There is one limitation to plan around. FQDN and DNS-based rules are rejected the moment you apply them, so you name destinations by address and identity, not by hostname.

Egress is the outbound half of segmentation. Start from for the two network layers and the default-deny baseline; this page covers what leaves the cluster and the FQDN gate in full.

Why FQDN and DNS rules are rejected here#

Rules that inspect a request need a userspace proxy on the node, and the node's datapath denies the proxy's return path today. A rule you apply would then silently break the traffic it is meant to permit, and a broken DNS rule can cut off name resolution for other workloads on the same node. A built-in admission policy (syself-restrict-l7-proxy-policies) rejects these rules at apply time with a readable error, so a bad rule never ships to break traffic quietly.

Rule family Status
Application-layer rules (rules.http, rules.kafka, rules.l7proto, rules.l7) Rejected permanently in a CiliumClusterwideNetworkPolicy; rejected for now in a namespaced CiliumNetworkPolicy
DNS rules (toPorts[].rules.dns, and toFQDNs, which needs one) Rejected for now in both kinds
Layer 3 and Layer 4 rules (identity, IP ranges, entities, ports, protocols) Work today, entirely in the kernel

The application-layer restriction is permanent in a clusterwide policy by design: it applies to every namespace at once, so one wrong request-level match can change how a workload another team owns behaves. In a namespaced policy the same rules lift once the datapath return path works and is verified. The DNS-rule restriction lifts once every node in the fleet runs the fixed node image, not in the release that first ships it, since a policy is cluster-scoped while the fix lands per node. explains the datapath and DNS gates under the hood; covers the admission policy itself.

Egress rules that work today#

Write egress on identity, address, and port. This policy lets a workload reach only its database and DNS, and nothing else outbound:

egress-policy.yamlyaml
		apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: app-egress
  namespace: team-web
spec:
  endpointSelector:
    matchLabels:
      app: web
  egress:
    # to the database pods, by identity
    - toEndpoints:
        - matchLabels:
            app: db
      toPorts:
        - ports:
            - {port: "5432", protocol: TCP}
    # to a specific external API, by CIDR
    - toCIDR:
        - "203.0.113.10/32"
      toPorts:
        - ports:
            - {port: "443", protocol: TCP}
    # DNS to CoreDNS (an L4 allow to kube-dns, not a DNS-content rule)
    - toEndpoints:
        - matchLabels:
            k8s-app: kube-dns
            io.kubernetes.pod.namespace: kube-system
      toPorts:
        - ports:
            - {port: "53", protocol: UDP}
            - {port: "53", protocol: TCP}
	

The DNS entry here opens port 53 and nothing more. It carries no rules.dns block, so it does not put the pod's lookups through Cilium's DNS proxy and none of the FQDN restrictions apply to it. Allowing DNS this way works today.

A default-deny egress baseline#

Flip a namespace's pods from "reach anything outbound" to "reach only what you allow" with a policy that selects every pod and lists only the egress you permit. With endpointSelector: {} and an egress list, anything not listed is denied. Always include the port-53 allow to CoreDNS, or the pods cannot resolve names and every outbound connection fails at the lookup.

When two rules overlap, the result is deny. There is no priority field, so you cannot write a broad deny and then allow one thing above it. Write each exception as its own selector.

When you need to name a destination by hostname#

Allow-listing an external service by hostname, or restricting by URL path, is not something egress policy does here today. Two options work around this:

  • Pin the destination by IP where it is stable, using toCIDR as above, or route the workload through an so the far end sees one stable source address. The egress gateway works today.
  • Use a service mesh for request-level HTTP control between your own services (allow only GET /api, per-path rules). Istio is supported on the platform; see .

Revisit FQDN policy when the datapath return-path limitation lifts. Until then, address and identity rules are the supported egress controls.

FQDN policy, for when the gate lifts#

toFQDNs allows outbound traffic to a named domain like api.github.com instead of an IP range. Cilium reads the pod's own DNS answers, watches which addresses a name resolves to, and permits exactly those. The pattern below is the one to use once the DNS-rule gate lifts, so you can plan for it. Applying it today is rejected.

The policy needs two rules, and both are required. The first names the allowed destination with toFQDNs. The second allows DNS with a rules.dns block on port 53, and that second rule is what puts the pod's lookups through Cilium's DNS proxy so Cilium ever sees an answer. Without it, Cilium never learns the address behind api.github.com, the toFQDNs rule matches nothing, and every connection to that name is dropped. Deleting the second rule to make the example shorter breaks it completely.

yaml
		apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: egress-github-only
  namespace: team-a
spec:
  endpointSelector:
    matchLabels: {app: ci-runner}
  egress:
    - toFQDNs:
        - matchName: "api.github.com"
      toPorts:
        - ports: [{port: "443", protocol: TCP}]
    - toEndpoints:
        - matchLabels:
            k8s:io.kubernetes.pod.namespace: kube-system
      toPorts:
        - ports: [{port: "53", protocol: UDP}]
          rules: {dns: [{matchPattern: "*"}]}
	

Only the name lookup passes through the DNS proxy. The connection to the resolved address that follows goes straight from the pod to that address on the eBPF fast path, enforced at Layer 3 and Layer 4 like any other connection.