# 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.

Published: 2026-11-05
Author: k8wiz Team
URL: https://k8wiz.com/articles/restrict-pod-egress-to-external-domains-with-cilium-tofqdns

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.

```bash
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.

```yaml
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.

```bash
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.

```bash
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.

```bash
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.

```bash
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.

```yaml
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`.
