On a fresh cluster any pod can reach any other pod, in any namespace. The host firewall protects the nodes from outside traffic, but it does not isolate your pods from each other. You close that gap with NetworkPolicy objects, and you own that layer. Start with a namespace-isolation baseline: one policy per namespace blocks cross-namespace traffic and rarely breaks anything. Then move to full default-deny for the namespaces that hold sensitive workloads, allowing each connection those workloads need. Do the isolation baseline first, everywhere; use full default-deny where it matters. ## The two network layers A Syself Autopilot cluster runs two separate network layers, with different owners. **The host firewall** protects each node's own services. It runs default-deny, keyed to cluster identity rather than address, and the platform manages it as a set of `CiliumClusterwideNetworkPolicy` objects. Do not edit these: the platform restores them. The open paths, the identity types, and how it works are covered in [Networking and Cilium](/docs/hetzner/apalla/concepts/internals/networking). **Workload policy** is the set of `NetworkPolicy` and `CiliumNetworkPolicy` objects you create. They select pod endpoints and control pod-to-pod traffic. This layer is entirely yours, and it starts empty. > [!WARNING] > The host firewall is default-deny for node traffic, but pod-to-pod traffic is default-allow until a policy selects a pod. The platform does not put a default-deny between your services, because only you know which of them should talk. On a fresh cluster, any pod reaches any other pod across any namespace. ## How Cilium enforces policy Cilium enforces network policy on identities derived from pod labels, not on IP addresses. A policy that says "frontend may reach backend" keeps working as pods restart and get new IPs. There is no IP list to maintain. The identity model also means no per-pod cloud infrastructure. Policy is not attached to a network interface, an IP address, or any object the cloud provider has to create and count. It runs in eBPF (a Linux kernel feature for fast, programmable network control) on each node, so the pods you can put under policy are bounded by the node, not by a provider quota. ## Policy types | Kind | Scope | Use it for | | -------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------- | | `NetworkPolicy` | Namespaced, L3/L4 (IP and port) | Standard segmentation, portable across clusters | | `CiliumNetworkPolicy` | Namespaced, adds deny rules and (once the gate lifts) domain-name egress | Deny rules, and egress to named hosts | | `CiliumClusterwideNetworkPolicy` | Cluster-wide | A baseline that applies across all namespaces | Use `NetworkPolicy` for ordinary segmentation. Use `CiliumNetworkPolicy` when you need a deny rule. For outbound control, including the FQDN limitation and the egress rules that work today, see [Control pod egress](/docs/hetzner/apalla/security/control-egress-with-fqdn-policy). ## What is restricted on this platform Application-layer (L7) and DNS rules are rejected the moment you apply them. `kubectl apply` fails with a readable error instead of the policy shipping and silently breaking the traffic it is meant to permit. Which rules are rejected, in which kind, and whether the rejection is permanent or temporary is in [Control pod egress](/docs/hetzner/apalla/security/control-egress-with-fqdn-policy); the `syself-restrict-l7-proxy-policies` admission policy that does the rejecting is covered in [Control admission](/docs/hetzner/apalla/security/control-admission). Everything else works today, and that is most of what you write. Rules on workload identity, IP ranges, ports, and protocols (Layer 3 and Layer 4) are not affected. That includes a rule that opens port 53 to the cluster's DNS service, which is what the default-deny baseline below needs: opening a port is a Layer 4 rule and carries no `rules.dns` block. For request-level HTTP control (allow only `GET /api`), use a service mesh; Istio is supported, see [Handle unreliable networks with Istio](/docs/hetzner/apalla/network/mesh/handle-unreliable-networks-with-istio). ## How deny and allow rules interact Cilium has no priority or ordering field on policies. Allow rules are additive: adding one only allows more traffic. The only way to narrow traffic a previous rule allows is a deny rule, and deny always beats allow. To narrow access, add an `ingressDeny` or `egressDeny` rule in your own policy object. Do not edit platform policies to remove an allow. Add your own deny on top. ## Start here: isolate a namespace One policy per namespace blocks all cross-namespace traffic while everything inside the namespace keeps working. This is the right first move on a shared cluster. It removes the biggest exposure, a compromised pod reaching every other namespace, without a long allow-list. ```yaml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: isolate-namespace namespace: team-a spec: podSelector: {} # selects every pod in team-a policyTypes: [Ingress] ingress: - from: - podSelector: {} # allows pods in team-a only ``` Two rules make this work. Once any policy selects a pod for ingress, all ingress no policy allows is denied. And a bare `podSelector` inside `from` matches pods in the policy's own namespace only. So pods in `team-a` still reach each other, and pods in every other namespace are dropped. The policy controls ingress only. Pods in `team-a` can still open any outbound connection, and DNS keeps working. Egress control comes with the full baseline below and in [Control pod egress](/docs/hetzner/apalla/security/control-egress-with-fqdn-policy). ### Allow the cross-namespace traffic a service needs After isolating the namespace, allow each legitimate cross-namespace path explicitly. The most common one is an ingress controller routing outside traffic to your pods (see [Expose an application](/docs/hetzner/apalla/network/expose/choose-how-to-expose)). With ingress-nginx installed in the `ingress-nginx` namespace, allow that namespace to reach the pods behind your `Ingress` objects: ```yaml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-ingress-controller namespace: team-a spec: podSelector: matchLabels: {app: frontend} # the pods your Ingress routes to policyTypes: [Ingress] ingress: - from: - namespaceSelector: matchLabels: {kubernetes.io/metadata.name: ingress-nginx} ports: - {protocol: TCP, port: 8080} ``` `kubernetes.io/metadata.name` is a label the API server puts on every namespace, so you can select a namespace by name without labeling it yourself. Allow rules are additive, so this combines with `isolate-namespace`: the `frontend` pods accept traffic from their own namespace and from ingress-nginx, and every other pod in `team-a` stays namespace-only. Repeat the pattern for each cross-namespace consumer, for example a Prometheus in a monitoring namespace scraping your metrics port. ## Move to full default-deny Full default-deny also controls traffic inside the namespace and all outbound traffic. Use it for namespaces that hold sensitive workloads, or wherever you want an explicit allow-list of every connection. Apply the steps to one namespace at a time and verify after each. ### Step 1: deny all traffic Apply a policy that selects all pods in the namespace and denies both incoming and outgoing traffic: ```yaml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny namespace: team-a spec: podSelector: {} # all pods in the namespace policyTypes: [Ingress, Egress] ``` After this, pods in `team-a` cannot reach anything, including DNS. Step 2 fixes that. ### Step 2: allow DNS DNS translates service names like `backend` into IP addresses. Almost every workload needs it. ```yaml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-dns namespace: team-a spec: podSelector: {} policyTypes: [Egress] egress: - to: - namespaceSelector: matchLabels: {kubernetes.io/metadata.name: kube-system} ports: - {protocol: UDP, port: 53} - {protocol: TCP, port: 53} ``` This policy opens a port 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 platform restrictions apply. Allowing DNS this way works today. Forget it, and every outbound connection fails at the name lookup. ### Step 3: allow the connections each workload needs Open specific pod-to-pod paths by label. For example, to let `frontend` pods reach `backend` pods on port 8080: ```yaml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: frontend-to-backend namespace: team-a spec: podSelector: matchLabels: {app: backend} policyTypes: [Ingress] ingress: - from: - podSelector: matchLabels: {app: frontend} ports: - {protocol: TCP, port: 8080} ``` Because Cilium tracks pods by label, this policy stays correct as pods restart and receive new IP addresses. Default-deny also drops traffic from an ingress controller. If an `Ingress` routes to pods in this namespace, keep the `allow-ingress-controller` policy from the isolation section. ### Step 4: control outbound traffic A default-deny policy already blocks all egress except what you allow (Step 2's DNS allow, and any pod-to-pod path you opened). To let a workload reach something outside the cluster, name it by address and port. Naming a destination by hostname with `toFQDNs` is rejected at apply time today, so you use `toCIDR` instead. The egress rules that work today, the default-deny egress baseline, and the FQDN limitation in full are in [Control pod egress](/docs/hetzner/apalla/security/control-egress-with-fqdn-policy). ### Step 5: verify ```console # The allowed path answers. Expect your backend's normal response: $ kubectl -n team-a exec deploy/frontend -- curl -m 3 http://backend:8080 # The same request from a pod without the frontend label is dropped. # Expect a timeout: curl: (28) Connection timed out after 3001 milliseconds $ kubectl -n team-a run probe --rm -i --restart=Never --image=curlimages/curl -- curl -m 3 http://backend:8080 # Watch the drops as they happen: $ hubble observe --namespace team-a --verdict DROPPED ``` Denied traffic times out instead of being refused, because Cilium drops the packets without answering. A quick refusal means you reached something that is not listening, not a policy drop. On a 1.36 cluster the Hubble relay flow API is TLS-only, so bare `hubble observe` fails with a TLS handshake error until you connect the CLI to the relay first. See [See network flows with Hubble](/docs/hetzner/apalla/observability/network-flows/see-flows-with-hubble). ## What to know Do not edit the platform's `CiliumClusterwideNetworkPolicy` host rules: the platform restores them. Write your own namespaced policies for workload isolation. Both the host firewall and your workload policies run in Cilium, so Hubble sees every drop. Watch the `hubble_drop_total` metric to catch a workload hitting a policy it should not. Pair a default-deny NetworkPolicy with a Pod Security Standard level (see [Enforce Pod Security Standards](/docs/hetzner/apalla/security/enforce-pod-security-standards)) and a ResourceQuota or LimitRange. Together they address the main risks in a shared namespace, which is also the pattern behind [Multi-tenant isolation for agencies](/docs/hetzner/apalla/security/multi-tenant-isolation-for-agencies). ## Related - [Networking and Cilium](/docs/hetzner/apalla/concepts/internals/networking): the host firewall and the two-layer model in full - [Control pod egress](/docs/hetzner/apalla/security/control-egress-with-fqdn-policy): outbound control and the FQDN limitation - [Encrypt pod traffic with WireGuard](/docs/hetzner/apalla/security/encrypt-pod-traffic-with-wireguard): encrypting cross-node pod traffic on the wire - [Enforce Pod Security Standards](/docs/hetzner/apalla/security/enforce-pod-security-standards) - [Control admission](/docs/hetzner/apalla/security/control-admission) - [Ports and listeners](/docs/hetzner/apalla/reference/ports-and-listeners)