Introduction

Knowing how to install OpenShift Virtualization on a disconnected cluster has become essential as more teams run virtual machines alongside their containers in secure, air-gapped environments.

OpenShift Virtualization, built on the upstream KubeVirt project, runs virtual machines natively alongside containers on a single OpenShift cluster – managed through the same API and console, and accelerated by KVM.

Installing it on a connected cluster is simple. In a disconnected (air-gapped) environment, however, two prerequisites are essential for success. First, the worker nodes must expose CPU virtualization extensions – straightforward on bare metal, but requiring extra steps when the nodes run as VMs. Second, VM images must be hosted in an internal registry rather than the internet.

This guide presents a complete, ordered procedure that takes an OpenShift 4.14 cluster from zero to running its first virtual machine.

Prerequisites

  • A disconnected OpenShift 4.14 cluster with cluster-admin access. This guide was performed on OpenShift 4.14; OpenShift Virtualization is available on other releases too, but the operator’s minor version must always match the cluster’s version. For version compatibility, see the Red Hat OpenShift Virtualization documentation linked at the end of this guide.
  • Worker nodes with hardware virtualization available – either bare-metal nodes, or VMs on a hypervisor that supports nested virtualization (with access to edit the VM CPU settings).
  • An internal registry the cluster already trusts, and a bastion host with internet access and the oc and podman utilities.
  • The OpenShift Virtualization operator mirrored into the internal registry (covered in Step 2). In a disconnected cluster, no operator appears in OperatorHub until it has been mirrored.

Why A Disconnected Cluster Is Different

Two conditions must hold before a virtual machine can run, and in a disconnected environment each must be handled deliberately.

First, hardware virtualization must be enabled on the nodes. Specifically, the operator schedules VMs only onto nodes that advertise devices.kubevirt.io/kvm, which exists only when the CPU exposes its virtualization extensions (vmx/svm). On bare metal these are present by default, but they can be hidden underneath. Without them, /dev/kvm never appears, the node never advertises devices.kubevirt.io/kvm, and the operator installs successfully while nothing can be scheduled onto it. Therefore, the fix, covered in Step 1, is to verify the extensions are present on the nodes before installing.

Second, the VM image must be delivered locally. Air-gapped nodes cannot pull an image from the internet, so it must be available in a registry they can already reach – the internal one. To do this, you package the VM disk as a containerDisk and push it there, so the VM boots by pulling a local image like any other container.

Together, these set the order of the guide: hardware virtualization first, then the operator and storage, then a locally delivered image.

Step 1: Verify Hardware Virtualization

Before installing anything, confirm that the worker nodes actually expose the CPU virtualization extensions the operator depends on. Specifically, the operator schedules virtual machines only onto nodes where /dev/kvm is present, which in turn requires the CPU to expose vmx (Intel) or svm (AMD). Therefore, this check is the gate for everything that follows.

Verify from the bastion:

$ oc debug node/<worker> -- chroot /host bash -c "egrep -c '(vmx|svm)' /proc/cpuinfo; ls /dev/kvm"

A non-zero count and the presence of /dev/kvm confirm success. If the count is 0 and /dev/kvm is missing, the extensions are not reaching the node and must be enabled at the infrastructure layer before continuing.

Step 2: Mirror The Operator And Match The Version

In a disconnected cluster, OperatorHub is empty by default – Operator Lifecycle Manager cannot reach Red Hat’s public catalogs, so an operator appears only after it has been mirrored into the internal registry. Essentially, a mirror is a local copy of the operator’s catalog and images, pulled by an internet-connected host and pushed to a registry the cluster can reach. Therefore, if OpenShift Virtualization already appears in your OperatorHub, it has already been mirrored, and you can skip to Step 3.

To mirror it, run the following on the bastion (internet-connected). First define an image set that selects the operator from the 4.14 catalog:

kind: ImageSetConfiguration
apiVersion: mirror.openshift.io/v1alpha2
mirror:
  operators:
    - catalog: registry.redhat.io/redhat/redhat-operator-index:v4.14
      packages:
        - name: kubevirt-hyperconverged
          channels:
            - name: stable

Then mirror it directly to the internal registry:

$ oc mirror --config=imageset-config.yaml docker://<internal-registry>

Applying the generated CatalogSource and ImageContentSourcePolicy (or ImageDigestMirrorSet) makes the operator appear in OperatorHub:

$ oc apply -f oc-mirror-workspace/results-*/

Note: OpenShift Virtualization is locked to the platform minor version – a 4.14 cluster requires a 4.14 operator. The patch (z-stream) need not match exactly.

Verify the package is now available and note the version the stable channel resolves to:

$ oc get packagemanifest kubevirt-hyperconverged -n openshift-marketplace \
  -o jsonpath='{.status.channels[*].currentCSV}{"\n"}'

Step 3: Install The Operator

The operator can be installed in two ways: through the web console (OperatorHub), or with the CLI. Either approach produces the same result; choose whichever your workflow.

Through the web console:

Navigate to Operators -> OperatorHub.

In the search box, type virtualization and select the OpenShift Virtualization.

Review the Operator details panel and click Install.

On the installation page, keep the defaults: Update channel stable, the Version that resolves for your cluster, Installed Namespace openshift-cnv (created automatically), and Update approval Automatic. Click Install.

Wait until the page reports that the operator installed and ready for use.

The CLI equivalent:

apiVersion: v1
kind: Namespace
metadata:
  name: openshift-cnv
apiVersion: operators.coreos.com/v1
kind: OperatorGroup
metadata:
  name: kubevirt-hyperconverged-group
  namespace: openshift-cnv
spec:
  targetNamespaces: ["openshift-cnv"]
apiVersion: operators.coreos.com/v1alpha1
kind: Subscription
metadata:
  name: hco-operatorhub
  namespace: openshift-cnv
spec:
  source: redhat-operators
  sourceNamespace: openshift-marketplace
  name: kubevirt-hyperconverged
  channel: "stable"

Verify:

$ oc get csv -n openshift-cnv

Should reach Succeeded.

Note: Once installed, operators in a disconnected cluster are kept current through the mirrored catalog. For the upgrade workflow, see the related guide linked at the end of this post.

Step 4: Deploy The HyperConverged Resource

The operator deploys nothing until the HyperConverged custom resource is created. As with the install, this can be done through the web console or the CLI.

Through the web console:

After the operator finishes installing, open it from Operators -> Installed Operators -> OpenShift Virtualization.

The page prompts that a required custom resource is needed. Click Create HyperConverged.

Keep the default name kubevirt-hyperconverged and accept the default settings.

The CLI equivalent:

apiVersion: hco.kubevirt.io/v1beta1
kind: HyperConverged
metadata:
  name: kubevirt-hyperconverged
  namespace: openshift-cnv
spec: {}

Note: The resource must keep the name kubevirt-hyperconverged.

Verify that the deployment is available and the nodes now advertise KVM:

$ oc wait hyperconverged kubevirt-hyperconverged -n openshift-cnv --for=condition=Available --timeout=15m
$ oc get nodes -o json | jq -r '.items[] | "\(.metadata.name): kvm=\(.status.allocatable["devices.kubevirt.io/kvm"] // "none")"'

Worker nodes should report a KVM count; control-plane nodes remain none. This confirms the full chain: host CPU -> /dev/kvm -> operator device plugin -> schedulable resource.

Step 5: Provide Storage

A VM disk is a PVC, which requires a StorageClass. The bundled HostPath Provisioner supplies local node storage with no external dependency:

apiVersion: hostpathprovisioner.kubevirt.io/v1beta1
kind: HostPathProvisioner
metadata:
  name: hostpath-provisioner
spec:
  imagePullPolicy: IfNotPresent
  storagePools:
    - name: local
      path: "/var/hpvolumes"
  workload:
    nodeSelector:
      node-role.kubernetes.io/worker: ""
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: hostpath-csi
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
provisioner: kubevirt.io.hostpath-provisioner
reclaimPolicy: Delete
volumeBindingMode: WaitForFirstConsumer

Note: HostPath storage is ReadWriteOnce and node-local; live migration requires shared ReadWriteMany storage (e.g. NFS or OpenShift Data Foundation).

Step 6: Deliver The VM Image Via The Internal Registry

This is the defining step of a disconnected install. Since the nodes cannot reach the internet, the VM disk is packaged as a containerDisk – a minimal OCI image – and pushed to the internal registry. All commands run on the bastion:

$ curl -L -o image.qcow2 "<cloud-image-url>"
$ cat > Containerfile <<'EOF'
FROM scratch
ADD image.qcow2 /disk/image.qcow2
EOF
$ podman build -t <internal-registry>/kubevirt/vm-disk:latest .
$ podman login <internal-registry>
$ podman push <internal-registry>/kubevirt/vm-disk:latest

Verify that a worker node can pull the image:

$ oc run pulltest --image=<internal-registry>/kubevirt/vm-disk:latest \
  --restart=Never --command -- true -n openshift-cnv
$ oc describe pod pulltest -n openshift-cnv | grep -i pulled
$ oc delete pod pulltest -n openshift-cnv

An event reading Successfully pulled image confirms reachability. Note that a FROM scratch image contains no binaries, so any container command will fail – this is expected and irrelevant; only the pull matters.

Step 7: Create And Verify The Virtual Machine

apiVersion: kubevirt.io/v1
kind: VirtualMachine
metadata:
  name: vm-test
  namespace: openshift-cnv
spec:
  running: true
  template:
    spec:
      domain:
        cpu: { cores: 1 }
        resources: { requests: { memory: 2Gi } }
        devices:
          disks:
            - { name: containerdisk, disk: { bus: virtio } }
            - { name: cloudinitdisk, disk: { bus: virtio } }
          interfaces: [ { name: default, masquerade: {} } ]
      networks: [ { name: default, pod: {} } ]
      volumes:
        - name: containerdisk
          containerDisk:
            image: <internal-registry>/kubevirt/vm-disk:latest
        - name: cloudinitdisk
          cloudInitNoCloud:
            userData: |
              #cloud-config
              password: changeme
              chpasswd: { expire: false }
              ssh_pwauth: true

A login prompt and a configured network interface confirm a fully operational VM.

Exit the console with Ctrl + ].

Once it is running, you can also open the virtual machine directly from the OpenShift web console under Virtualization -> VirtualMachines, where its status, console, and resources usage are all visible in one place:

Troubleshooting

  • Insufficient devices.kubevirt.io/kvm – hardware virtualization is not reaching the node. Re-verify Step 1: CPU type host, a full power cycle, and nested virtualization enabled on the hypervisor host.
  • virtctl image-upload hangs – on local storage the upload populator can deadlock and its scratch volume may fail to schedule. Therefore, the containerDisk method in Step 6 avoids this entirely and is the preferred path for disconnected clusters.
  • Image pull fails (unauthorized / certificate error) – add registry credentials to the cluster pull secret, or add the registry CA to the cluster image configuration. However, if operators already mirror from this registry, trust is typically in place.

Summary

In this post, we demonstrated how to successfully install OpenShift Virtualization on a disconnected cluster, from preparing the platform to booting a running virtual machine. Specifically, we verified hardware virtualization on the worker nodes, mirrored and installed the operator from the internal registry, deployed the HyperConverged resource, configured a default StorageClass, and delivered a VM image as a containerDisk served entirely from within the air-gapped environment.

Ultimately, this approach lets you run traditional VM-based and legacy applications alongside container workloads on a single platform – the same consolidation that leads many organizations to run OpenShift directly on bare metal and host their virtual machines on the same cluster.

If air-gapped environments are a big part of your work, you might find these articles very helpful as well:

References And Further Reading