k8wiz
Back to Articles

Ready to test your Cilium knowledge? Try our Cilium quiz!

How to restrict pod egress to external domains with Cilium toFQDNs

Allow a pod to reach only the external domains it needs with a Cilium toFQDNs policy, then verify it with Hubble and fix the usual DNS failures.

8 min read
By k8wiz Team
  • cilium
  • network-policy
  • dns
  • egress

A payments service needs to call one external API. Nothing else on the internet should be reachable from it. With a standard Kubernetes NetworkPolicy you would list the API's IP ranges in an ipBlock, and that list goes stale the next time the provider moves a load balancer. SaaS endpoints behind CDNs change IP addresses all the time.

Cilium solves this with toFQDNs. You write the domain name in the policy, and Cilium works out which IPs to allow by watching the pod's own DNS lookups. This guide builds such a policy step by step, checks that it works, and covers the failures people run into first. The policy fields and commands come from the Cilium 1.20 documentation.

How toFQDNs works

Cilium can't resolve the domain itself and allow the result. Big providers return different IPs to different clients, and the IPs your pod gets are the ones that matter. So the Cilium agent on each node runs a DNS proxy. When a policy asks for it, the pod's DNS traffic goes through that proxy, and Cilium records every IP that comes back for a matching name.

Each toFQDNs rule then acts like a CIDR rule that Cilium fills in at run time. When the pod looks up api.stripe.com, the proxy sees the answer, adds those IPs to the policy, and only then passes the response to the pod. By the time the application opens a connection, the IPs are already allowed.

Two consequences follow. A toFQDNs rule does nothing on its own; the policy must also send DNS through the proxy. And the pod can only reach IPs it has looked up itself while the policy was in place.

Step 1: check that the L7 proxy is on

The DNS proxy is part of Cilium's Layer 7 proxy. It is on by default, and the Helm value is l7Proxy. Check the running configuration before you rely on it.

kubectl -n kube-system get configmap cilium-config -o jsonpath='{.data.enable-l7-proxy}{"\n"}'

The command should print true. If it prints false, set l7Proxy=true in your Cilium Helm values and upgrade the release, or toFQDNs rules will never learn any IPs.

Step 2: write the policy

The policy below selects the pods labeled app: payments in the shop namespace. It has two egress rules.

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: payments-egress
  namespace: shop
spec:
  endpointSelector:
    matchLabels:
      app: payments
  egress:
    - toEndpoints:
        - matchLabels:
            k8s:io.kubernetes.pod.namespace: kube-system
            k8s:k8s-app: kube-dns
      toPorts:
        - ports:
            - port: "53"
              protocol: ANY
          rules:
            dns:
              - matchPattern: "*"
    - toFQDNs:
        - matchName: api.stripe.com
      toPorts:
        - ports:
            - port: "443"
              protocol: TCP

The first rule lets the pods talk to kube-dns on port 53. The rules.dns block is what sends that traffic through the DNS proxy. matchPattern: "*" allows every query, so the proxy watches all lookups without blocking any. Leave out the rules block and DNS still works, but the proxy never sees it and the toFQDNs rule stays empty.

The second rule allows TCP 443 to whatever IPs api.stripe.com resolves to. Adding toPorts here is optional, but it's good practice. Without it, the pod could reach those IPs on any port.

By default, as soon as a policy with egress rules selects a pod, Cilium denies every other egress connection from it. That is the point here, but it also means the pod loses access to anything you forget, such as an internal service in another namespace. Add an extra toEndpoints rule for each one. A rule that contains toFQDNs can't also contain toEndpoints or toCIDR, so put those in separate rules in the same egress list.

Apply it.

kubectl apply -f payments-egress.yaml

Step 3: test from the pod

Run a lookup and a request from inside a payments pod. The -w option prints the HTTP status, which is enough to show the connection got through.

kubectl -n shop exec deploy/payments -- curl -sS -o /dev/null -w "%{http_code}\n" https://api.stripe.com/v1
kubectl -n shop exec deploy/payments -- curl -sS -m 5 https://example.com

The first command should print an HTTP status code. Any status means the TCP and TLS connection worked, even if the API answers 401 because there's no key. The second should time out, because example.com isn't in the policy. The image needs curl for this test; use any image that has it if your application image doesn't.

Step 4: see what Cilium learned

To see the IPs behind a rule, ask the Cilium agent on the node where the pod runs. First find the node, then the agent pod on it.

NODE=$(kubectl -n shop get pod -l app=payments -o jsonpath='{.items[0].spec.nodeName}')
AGENT=$(kubectl -n kube-system get pod -l k8s-app=cilium \
  --field-selector spec.nodeName="$NODE" -o jsonpath='{.items[0].metadata.name}')
kubectl -n kube-system exec "$AGENT" -c cilium-agent -- cilium-dbg fqdn cache list

The output lists, for each endpoint, the names it looked up, the TTL and the IPs Cilium stored. If api.stripe.com. is missing, the proxy never saw the lookup, and step 5 explains the usual causes.

Hubble shows the same story from the traffic side. These commands show the DNS lookups from the payments pods and the flows Cilium dropped.

hubble observe --namespace shop --protocol dns --last 20
hubble observe --namespace shop --verdict DROPPED --last 20
hubble observe --from-pod shop/payments-7d4b9c-x2k8p --to-fqdn api.stripe.com --last 20

A dropped flow to an IP that belongs to the allowed domain usually means the pod connected without a fresh lookup that the proxy saw.

Step 5: fix the common failures

Every connection to the domain is dropped

Check the DNS rule first. Without the rules.dns block, or with DNS traffic going to a resolver the rule doesn't select, the proxy learns nothing. Pods that use a node-local DNS cache or a custom resolver need a toEndpoints or toCIDR rule for that resolver, with the same rules.dns block. When DNS runs behind a Kubernetes Service on a non-standard port, the port in the rule must be the backend port, not the Service port.

It worked, then it stopped after a restart of the pod or policy

Applications that cache DNS answers can keep using an IP they resolved before the policy existed. The proxy never saw that answer, so Cilium never allowed the IP. Restart the pod after you apply the policy so every lookup goes through the proxy. Java services are a classic case, since the JVM can cache lookups for a long time depending on its security settings.

Lookups fail with REFUSED once you tighten the DNS rule

Allowing every query with matchPattern: "*" is the easy start. Once you replace it with a short list, such as matchName: api.stripe.com, other lookups get a DNS REFUSED answer from the proxy. That includes the search-list attempts most pods make first. With the default ndots:5, a lookup for api.stripe.com is first tried as api.stripe.com.shop.svc.cluster.local. and similar names.

Some resolvers, notably musl in Alpine images, stop at the first REFUSED instead of trying the next name, so the lookup fails even though the real name is allowed. You have three fixes. Allow the cluster names with matchPattern: "*.*.svc.cluster.local." and similar patterns. Lower ndots in the pod's dnsConfig so external names skip the search list. Or change the proxy's reject answer to NXDOMAIN with the Helm value dnsProxy.dnsRejectResponseCode=nameError, which makes resolvers move on to the next name.

spec:
  dnsConfig:
    options:
      - name: ndots
        value: "2"

Wildcards don't match what you expect

matchPattern treats * as any characters except a dot. *.stripe.com matches api.stripe.com but not stripe.com and not files.eu.stripe.com. To match every level of subdomain, use the **. prefix, as in **.stripe.com, which still doesn't match stripe.com itself. List the bare domain as its own matchName if you need it.

Things to keep in mind in production

The DNS proxy runs inside the Cilium agent. While an agent restarts, DNS requests from pods that a DNS rule selects depend on it, so roll out Cilium upgrades one node at a time and watch for lookup errors.

Each distinct set of names that an IP matches gets its own security identity. With hundreds of toFQDNs selectors and many shared CDN IPs, that number can grow quickly. Cilium exposes the identity_label_sources{type="fqdn"} metric so you can watch it, and the fqdn_selectors metric for the number of selectors.

Finally, a domain is not a security boundary on its own. Allowing **.amazonaws.com lets the pod reach any customer's bucket on that service, including one an attacker controls. Allow the narrowest names that work and pair them with toPorts.

Test yourself

Ready to test your Cilium knowledge? Try our Cilium quiz!