Skip to content

UDN private subnet

You want two virtual machines on their own isolated network - unreachable from anywhere else in your cluster by default - that can still talk to each other and reach the internet in a controlled way. On AWS you'd reach for a VPC private subnet with a Security Group. On OpenShift, the equivalent is a User Defined Network (UDN) paired with a matching set of NetworkPolicy objects.

This example builds exactly that: two Fedora VMs in their own Layer2 subnet, isolated by default, able to reach each other, and gated to HTTPS + DNS egress only - the same shape as a Security Group rule.

Prerequisites

  • An OpenShift/OKD cluster with OVN-Kubernetes as the network type
  • The userdefinednetworks.k8s.ovn.org CRD installed (ships with OCP/OKD 4.17+)
  • OpenShift Virtualization installed, with a fedora boot-source DataSource available
  • oc and virtctl on your workstation

Ready to run it? Jump to the step-by-step guide. Keep reading for how it works.

The AWS analogy

flowchart LR
    subgraph AWS["AWS"]
        direction TB
        SG["Security Group<br/>self-ref: allow all<br/>egress 443/dns only"]
        EC2A["EC2 A<br/>10.0.1.11"]
        EC2B["EC2 B<br/>10.0.1.12"]
        EC2A <-->|allowed| EC2B
    end
    subgraph OCP["OpenShift"]
        direction TB
        NP["NetworkPolicy<br/>self-ref: allow all<br/>egress 443/dns only"]
        VMA["vm-a<br/>10.200.0.9"]
        VMB["vm-b<br/>10.200.0.10"]
        VMA <-->|allowed| VMB
    end
    Outside1["outside the VPC"] -.->|blocked| EC2A
    Outside2["outside the namespace"] -.->|blocked| VMA

Same mental model, different platform: a private subnet plus a self-referencing egress rule.

Why a Primary UDN, not a Secondary one

A UserDefinedNetwork with role: Primary replaces your namespace's default pod network entirely, instead of adding an extra interface alongside it (role: Secondary). That's what makes the isolation structural rather than something you configure after the fact - and because it's the pod's primary interface, plain Kubernetes NetworkPolicy applies to it directly, no CNI-specific policy object required.

userdefinednetwork-private-subnet.yaml
apiVersion: k8s.ovn.org/v1
kind: UserDefinedNetwork
metadata:
  name: private-subnet
  namespace: udn-demo
spec:
  topology: Layer2
  layer2:
    role: Primary
    subnets:
    - 10.200.0.0/24
    ipam:
      lifecycle: Persistent

topology: Layer2 gives you one flat subnet shared by every node - the closest match to an AWS subnet, which is also just one CIDR block. ipam.lifecycle: Persistent keeps a VM's IP stable across restarts, the same way an AWS ENI keeps its private IP when you stop and start an instance.

Two things that aren't obvious the first time

The namespace needs a label before it exists, not after

A Primary UDN needs the k8s.ovn.org/primary-user-defined-network label on its namespace. On most clusters, a ValidatingAdmissionPolicy blocks adding that label to a namespace that already exists - so it has to ship in the namespace manifest from the start. It already is, in namespace-udn-demo.yaml.

Egress NetworkPolicy has no \"same namespace is free\" rule

If you write a default-deny-egress policy with podSelector: {}, it blocks traffic between your own VMs too - ICMP, SSH, everything - not just traffic leaving the namespace. The fix is the same pattern you'd already reach for in an AWS Security Group: a self-referencing rule that lets members of the group talk to each other.

networkpolicy-allow-intra-namespace-egress.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-intra-namespace-egress
  namespace: udn-demo
spec:
  podSelector: {}
  policyTypes:
  - Egress
  egress:
  - to:
    - podSelector: {}

Use bridge interface binding, not masquerade

masquerade binding layers KubeVirt's own internal NAT on top of the network attachment - built for the default pod network's single-IP constraint. On a Primary UDN, your VM's pod already gets a real routable address directly, so masquerade just adds a confusing extra translation layer, where the guest's own reported IP ends up different from its actual address. bridge binding attaches the guest straight to the UDN - no translation, no mismatch.

What's next

Run it yourself, step by step →