Skip to content

Configure Custom Ingress for KubeVirt HCP

This recipe walks through deploying a KubeVirt-based Hosted Control Plane with a custom baseDomain (without baseDomainPassthrough) on a bare-metal management cluster using MetalLB for LoadBalancer services.

This is the typical setup when the guest cluster needs its own DNS domain separate from the management cluster's *.apps domain, and an external load balancer (F5, HAProxy, etc.) or MetalLB handles VIP advertisement.

Prerequisites

  • A bare-metal OpenShift management cluster with KubeVirt (OpenShift Virtualization) installed
  • MetalLB Operator installed (see Optional MetalLB Configuration Steps)
  • A DNS zone you control for the custom baseDomain
  • VM network configured with a secondary bridge interface (the VMs must have machineNetwork connectivity, not just pod network)

Environment Variables

Set these once — all subsequent commands reference them:

export CLUSTER_NAME=my-kubevirt-hcp
export BASE_DOMAIN=example.com
export HCP_NAMESPACE="clusters-${CLUSTER_NAME}"
export PULL_SECRET="$HOME/pull-secret"
export MEM="6Gi"
export CPU="2"
export WORKER_COUNT="2"

Step 1 — Create the HostedCluster

hcp create cluster kubevirt \
  --name $CLUSTER_NAME \
  --node-pool-replicas $WORKER_COUNT \
  --pull-secret $PULL_SECRET \
  --memory $MEM \
  --cores $CPU \
  --base-domain $BASE_DOMAIN

Because --base-domain is provided, the webhook does not enable baseDomainPassthrough. The cluster will stay in Partial progress until ingress is manually configured.

Step 2 — Configure MetalLB

2.1 — Create the MetalLB instance

apiVersion: metallb.io/v1beta1
kind: MetalLB
metadata:
  name: metallb
  namespace: metallb-system

2.2 — Create the IPAddressPool

Adjust the address range to match available IPs on your bare-metal network:

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: hcp-ingress-pool
  namespace: metallb-system
spec:
  addresses:
  - 192.168.216.32-192.168.216.122

2.3 — Create the L2Advertisement

If your network uses a specific bridge interface (e.g., br-sdn), add interfaces and nodeSelectors as needed:

apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: hcp-ingress-l2
  namespace: metallb-system
spec:
  ipAddressPools:
  - hcp-ingress-pool

Step 3 — Retrieve the Guest Router NodePorts

Wait for the guest cluster to have running worker nodes, then extract the dynamically assigned NodePorts:

export CLUSTER_KUBECONFIG="${CLUSTER_NAME}-kubeconfig"
hcp create kubeconfig --name $CLUSTER_NAME > $CLUSTER_KUBECONFIG

export HTTP_NODEPORT=$(oc --kubeconfig $CLUSTER_KUBECONFIG get services \
  -n openshift-ingress router-nodeport-default \
  -o jsonpath='{.spec.ports[?(@.name=="http")].nodePort}')

export HTTPS_NODEPORT=$(oc --kubeconfig $CLUSTER_KUBECONFIG get services \
  -n openshift-ingress router-nodeport-default \
  -o jsonpath='{.spec.ports[?(@.name=="https")].nodePort}')

echo "HTTP NodePort: $HTTP_NODEPORT"
echo "HTTPS NodePort: $HTTPS_NODEPORT"

Step 4 — Retrieve VM machineNetwork IPs

oc get vmi -n $HCP_NAMESPACE \
  -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.interfaces[0].ipAddress}{"\n"}{end}'

Example output:

my-kubevirt-hcp-workers-abc12-xyz34    192.168.216.50
my-kubevirt-hcp-workers-abc12-xyz56    192.168.216.51

Warning

Use the machineNetwork IPs (the VM's network interface on the secondary bridge), not the virt-launcher pod IPs. The guest router's NodePort only listens on machineNetwork IPs. Using pod IPs causes connection refused errors. See Ingress and DNS - Troubleshooting for details.

Step 5 — Create the LoadBalancer Service (no selector)

apiVersion: v1
kind: Service
metadata:
  labels:
    app: ${CLUSTER_NAME}
  name: ${CLUSTER_NAME}-apps-ingress
  namespace: ${HCP_NAMESPACE}
spec:
  ports:
  - name: https-443
    port: 443
    protocol: TCP
    targetPort: ${HTTPS_NODEPORT}
  - name: http-80
    port: 80
    protocol: TCP
    targetPort: ${HTTP_NODEPORT}
  type: LoadBalancer

The Service has no selector. Traffic routing is handled entirely by the EndpointSlice below.

Step 6 — Create the EndpointSlice

Replace IP addresses with the values from Step 4:

apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
  name: ${CLUSTER_NAME}-apps-ingress
  namespace: ${HCP_NAMESPACE}
  labels:
    kubernetes.io/service-name: ${CLUSTER_NAME}-apps-ingress
addressType: IPv4
ports:
- name: https-443
  port: ${HTTPS_NODEPORT}
  protocol: TCP
- name: http-80
  port: ${HTTP_NODEPORT}
  protocol: TCP
endpoints:
- addresses:
  - "192.168.216.50"
  - "192.168.216.51"

Step 7 — Configure Wildcard DNS

Get the VIP assigned by MetalLB:

export EXTERNAL_IP=$(oc -n $HCP_NAMESPACE get service ${CLUSTER_NAME}-apps-ingress \
  -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo "LoadBalancer VIP: $EXTERNAL_IP"

Create a wildcard DNS record:

*.apps.my-kubevirt-hcp.example.com.  IN  A  <EXTERNAL_IP>

Verify:

dig +short test.apps.${CLUSTER_NAME}.${BASE_DOMAIN}

Important

DNS must resolve correctly both externally and from inside the guest VMs. If the VMs resolve *.apps to the MetalLB VIP but the return traffic path is broken (hairpin), the ingress canary checks will fail. Common fixes: enable SNAT on the MetalLB L2 advertisement, or use split-horizon DNS so guest VMs resolve directly to their own machineNetwork IPs.

Tip

As an alternative to split-horizon DNS, you can inject custom DNS configuration directly into the KubeVirt VMs using a JSON patch on the NodePool. This overrides the VM's DNS resolver so it points to a nameserver that returns the correct IPs from inside the guest network:

apiVersion: hypershift.openshift.io/v1beta1
kind: NodePool
metadata:
  name: my-kubevirt-hcp
  namespace: clusters
spec:
  platform:
    kubevirt:
      jsonPatch:
      - op: add
        path: /spec/template/spec/dnsPolicy
        value: "None"
      - op: add
        path: /spec/template/spec/dnsConfig
        value:
          nameservers:
          - "10.0.0.53"
          searches:
          - "example.com"

See Configuring VMs with JSON Patch for full details on the JSON patch mechanism.

Step 8 — Verify

Check HostedCluster progresses to Completed:

oc get --namespace clusters hostedclusters

Expected output:

NAME              VERSION   KUBECONFIG                         PROGRESS    AVAILABLE   PROGRESSING   MESSAGE
my-kubevirt-hcp   4.17.0    my-kubevirt-hcp-admin-kubeconfig   Completed   True        False         The hosted control plane is available

Verify ingress from outside:

curl -vk https://console-openshift-console.apps.${CLUSTER_NAME}.${BASE_DOMAIN}

Check the ingress operator is not degraded inside the guest:

oc --kubeconfig $CLUSTER_KUBECONFIG get co ingress

Maintenance

The EndpointSlice is not automatically managed. Update it when:

Event Action
Scale up (new VMs) Add new VM machineNetwork IPs to the EndpointSlice
Scale down Remove decommissioned VM IPs
Live migration Update IPs if machineNetwork address changed

Quick command to get current VM IPs:

oc get vmi -n $HCP_NAMESPACE \
  -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.interfaces[0].ipAddress}{"\n"}{end}'

Traffic Flow

Client
  └─> *.apps.my-kubevirt-hcp.example.com     (DNS wildcard)
       └─> MetalLB VIP (e.g. 192.168.216.32) (L2 advertisement)
            └─> VM machineNetwork IP          (EndpointSlice)
                 └─> NodePort (e.g. 31245)    (guest router)
                      └─> guest Route         (application)