This guide walks you through building a Windows Server 2022 node image for vSphere Kubernetes Service (VKS) from scratch. This image allows you to run Windows container workloads on your Kubernetes clusters.
What you'll create: An OVA file containing a customized Windows Server 2022 image with Kubernetes components (kubelet, containerd, etc.) pre-installed.
- vCenter Server 8.0 or later
- DHCP-enabled network (Packer requires DHCP - static IP is not supported)
- Datastore with at least 50GB free space
# Check versions
docker --version # Required: >= 20.10.21
make --version # Required: >= 4.2.1
jq --version # Required: >= 1.6
# Optional but recommended
govc version # For managing VMs in vCenter- Windows Server 2022 ISO - Download from MSDN or Volume Licensing portal
- Example:
en-us_windows_server_2022_updated_april_2022_x64_dvd_d428acee.iso
- Example:
- VMware Tools ISO - Download from Broadcom Knowledge Base
- Example:
VMware-tools-windows-12.5.0-23800621.iso
- Example:
When installing Windows unattended, you need a product key. For enterprise/volume licensing environments, Microsoft provides KMS Client Setup Keys (also called GVLK - Generic Volume License Keys).
These are NOT pirated keys - they are officially published by Microsoft for use with Key Management Service (KMS) activation in enterprise environments.
Microsoft publishes them at: https://learn.microsoft.com/en-us/windows-server/get-started/kms-client-activation-keys
| Edition | KMS Client Key |
|---|---|
| Standard | VDYBN-27WPP-V4HQT-9VMD4-VMK7H |
| Datacenter | WX4NM-KYWYW-QJJR4-XV3QB-6VM33 |
- During image build: The KMS key allows Windows to install without manual input
- After deployment: Windows nodes activate against your organization's KMS server
- For evaluation: Windows runs in evaluation mode if no KMS server is available
# Clone the VKS Image Builder repository
git clone https://github.com/vmware/vks-image-builder.git
cd vks-image-builder
# Check out the branch for your desired Kubernetes version
git checkout release-1.34 # For Kubernetes 1.34
# Navigate to the image builder directory
cd vsphere-tanzu-kubernetes-grid-image-builderYou need to upload both ISOs to a datastore accessible by vCenter.
# Set environment variables
export GOVC_URL="your-vcenter.domain.com"
export GOVC_USERNAME="administrator@vsphere.local"
export GOVC_PASSWORD='YourPassword'
export GOVC_INSECURE=1
export GOVC_DATACENTER="YourDatacenter"
export GOVC_DATASTORE="YourDatastore"
# Upload Windows ISO
govc datastore.upload \
--ds="$GOVC_DATASTORE" \
--dc="$GOVC_DATACENTER" \
/path/to/windows_server_2022.iso \
iso/windows2022.iso
# Upload VMware Tools ISO
govc datastore.upload \
--ds="$GOVC_DATASTORE" \
--dc="$GOVC_DATACENTER" \
/path/to/VMware-tools-windows.iso \
iso/vmtools.iso- Log into vCenter web client
- Navigate to Storage > Your Datastore > Files
- Create an
isofolder - Upload both ISO files
Edit packer-variables/vsphere.j2:
{
"vcenter_server": "your-vcenter.domain.com",
"username": "administrator@vsphere.local",
"password": "YourPassword",
"datacenter": "YourDatacenter",
"datastore": "YourDatastore",
"folder": "",
"cluster": "YourCluster",
"network": "YourNetwork",
"insecure_connection": "true",
"linked_clone": "true",
"create_snapshot": "true",
"destroy": "false"
}Important: The network MUST have DHCP enabled.
Edit packer-variables/windows/vsphere-windows.j2:
{
"os_iso_path": "[YourDatastore] iso/windows2022.iso",
"vmtools_iso_path": "[YourDatastore] iso/vmtools.iso"
}Note: The format is [DatastoreName] path/to/file.iso with a space after the bracket.
The autounattend.xml file controls the unattended Windows installation. You need to make two critical changes:
curl https://raw.githubusercontent.com/kubernetes-sigs/image-builder/refs/heads/main/images/capi/packer/ova/windows/windows-2022-efi/autounattend.xml \
-o windows_autounattend.xmlFind the <UserData> section and add the <ProductKey> element:
<UserData>
<AcceptEula>true</AcceptEula>
<FullName>Administrator</FullName>
<Organization>Organization</Organization>
<ProductKey>
<Key>VDYBN-27WPP-V4HQT-9VMD4-VMK7H</Key>
</ProductKey>
</UserData>Find the <InstallFrom> section and set the image index:
Before (won't work):
<Value>{{user `windows_image_index`}}</Value>After (hardcoded):
<Value>2</Value>| Index | Edition |
|---|---|
| 1 | Standard (Server Core - no GUI) |
| 2 | Standard (Desktop Experience - with GUI) |
| 3 | Datacenter (Server Core - no GUI) |
| 4 | Datacenter (Desktop Experience - with GUI) |
Recommendation: Use index 2 for Standard with Desktop Experience.
# Remove old containers
make clean-containersmake run-artifacts-container ARTIFACTS_CONTAINER_PORT=8081# Check container is running
docker ps --filter "label=byoi_artifacts"
# Test the endpoint (IMPORTANT - do this before building!)
curl -s http://127.0.0.1:8081/artifacts/metadata/kubernetes_config.json | head -5Expected output:
{
"kubernetes": "v1.34.1+vmware.1",
"coredns": "v1.12.1+vmware.5-fips",
...
}If this fails, DO NOT proceed to the build step!
make build-node-image \
OS_TARGET=windows-2022-efi \
TKR_SUFFIX=vkr.4 \
HOST_IP=<YOUR_BUILD_MACHINE_IP> \
IMAGE_ARTIFACTS_PATH=$(pwd)/image-artifacts \
ARTIFACTS_CONTAINER_PORT=8081 \
AUTO_UNATTEND_ANSWER_FILE_PATH=$(pwd)/windows_autounattend.xmlReplace <YOUR_BUILD_MACHINE_IP> with the IP address of your build machine that is reachable from vCenter.
docker logs -f v1.34.1---vmware.1-windows-2022-efi-image-builderdocker logs v1.34.1---vmware.1-windows-2022-efi-image-builder 2>&1 | tail -50| Stage | Description | Duration |
|---|---|---|
| Creating virtual machine | VM created in vCenter | ~1 min |
| Waiting for IP | Windows installing from ISO | 10-20 min |
| WinRM connected | Packer connected to Windows | - |
| Provisioning with Ansible | Installing K8s components | 15-30 min |
| Exporting virtual machine | Creating OVA file | 5-10 min |
Total build time: 30-60 minutes
ls -la image-artifacts/ovas/You should see a file like:
windows-2022-efi-kube-v1.34.1-vkr.4-20260121.ova
ls -la image-artifacts/logs/Symptom:
docker ps -a
# Shows container with "Exited (4)" status
Cause: Artifacts container was not running.
Solution:
- Start artifacts container first:
make run-artifacts-container - Verify it's accessible:
curl http://127.0.0.1:8081/artifacts/metadata/kubernetes_config.json - Then start the build
Symptom: vCenter console shows this error during Windows install.
Cause: Missing or invalid ProductKey in autounattend.xml.
Solution: Add the ProductKey section to your autounattend.xml:
<ProductKey>
<Key>VDYBN-27WPP-V4HQT-9VMD4-VMK7H</Key>
</ProductKey>Symptom: Instead of automatic install, Windows asks you to choose an edition.
Cause: The image index is not being set correctly (Packer variable not replaced).
Solution: Hardcode the image index in autounattend.xml:
<Value>2</Value>Symptom: Logs show Waiting for IP... for more than 20 minutes.
Causes & Solutions:
- Check vCenter console - Look for Windows installation errors
- DHCP not working - Verify the VM's network has DHCP
- ISO mount issues - Verify ISO paths are correct in vsphere-windows.j2
Symptom:
ERROR DURING WINRM SEND INPUT - attempting to recover: ReadTimeout
Cause: Normal - Windows is slow to respond, Ansible will retry.
Solution: Wait - these are usually recoverable. Only worry if the build completely fails.
export GOVC_URL="your-vcenter.domain.com"
export GOVC_USERNAME="administrator@vsphere.local"
export GOVC_PASSWORD='YourPassword'
export GOVC_INSECURE=1
export GOVC_DATACENTER="YourDatacenter"govc find . -type m -name "windows-2022-efi-kube*"govc vm.power -off -force "./vm/windows-2022-efi-kube-v1.34.1-TIMESTAMP"
govc vm.destroy "./vm/windows-2022-efi-kube-v1.34.1-TIMESTAMP"- Download OVA from
image-artifacts/ovas/ - In vCenter, go to Content Libraries
- Create or select a local library
- Upload the OVA as a new item
- Important: Disable security policies for Windows images
- Use vSphere Kubernetes Service 3.4+ documentation
- Windows nodes require
best-effort-largeor larger VM class - You need both Linux (for control plane) and Windows node images
| File | Purpose |
|---|---|
packer-variables/vsphere.j2 |
vCenter connection |
packer-variables/windows/vsphere-windows.j2 |
ISO paths |
packer-variables/windows/default-args-windows.j2 |
Build defaults |
windows_autounattend.xml |
Windows installation settings |
# Clean up
make clean-containers
# Start artifacts
make run-artifacts-container ARTIFACTS_CONTAINER_PORT=8081
# Build
make build-node-image OS_TARGET=windows-2022-efi ...
# Monitor
docker logs -f v1.34.1---vmware.1-windows-2022-efi-image-builder
# Check output
ls image-artifacts/ovas/| Edition | Key |
|---|---|
| Server 2022 Standard | VDYBN-27WPP-V4HQT-9VMD4-VMK7H |
| Server 2022 Datacenter | WX4NM-KYWYW-QJJR4-XV3QB-6VM33 |
Source: https://learn.microsoft.com/en-us/windows-server/get-started/kms-client-activation-keys
- VMware VKS Image Builder
- VKS Windows Documentation
- Microsoft KMS Client Keys
- Windows Answer File Reference
This section shows the exact configuration files that were modified from the default repository.
{
"vcenter_server":"vc-wld01-a.site-a.vcf.lab",
"username":"administrator@wld.sso",
"password":"VMware123!VMware123!",
"datacenter":"wld-01a-DC",
"datastore":"cluster-wld01-01a-vsan01",
"folder":"",
"cluster": "cluster-wld01-01a",
"network": "ryantest-public",
"insecure_connection": "true",
"linked_clone": "true",
"create_snapshot": "true",
"destroy": "false"
}{
"os_iso_path": "[cluster-wld01-01a-vsan01] iso/en-us_windows_server_2022_updated_april_2022_x64_dvd_d428acee.iso",
"vmtools_iso_path": "[cluster-wld01-01a-vsan01] iso/windows.iso"
}Key changes:
- Added
memoryandcpusettings (8GB RAM, 4 CPU) - Added WinRM timeout settings in
ansible_user_vars - Added
windows_product_key
{
"memory": "8192",
"cpu": "4",
"additional_executables_destination_path": "C:\\ProgramData\\Temp",
"additional_executables_list": "http://{{ host_ip }}:{{ artifacts_container_port }}/artifacts/{{ kubernetes_version }}/bin/windows/amd64/registry.exe,http://{{ host_ip }}:{{ artifacts_container_port }}/artifacts/{{ kubernetes_version }}/bin/windows/amd64/goss.exe",
"additional_executables": "true",
"additional_url_images": "false",
"additional_url_images_list": "",
"additional_prepull_images": "",
"build_version": "{{ os_type }}-kube-{{ kubernetes_series }}-{{ ova_ts_suffix }}",
"cloudbase_init_url": "http://{{ host_ip }}:{{ artifacts_container_port }}/artifacts/{{ kubernetes_version }}/bin/windows/amd64/CloudbaseInitSetup_x64.msi",
"cloudbase_real_time_clock_utc": "true",
"containerd_url": "http://{{ host_ip }}:{{ artifacts_container_port }}/artifacts/{{ kubernetes_version }}/bin/windows/amd64/cri-containerd.tar",
"containerd_sha256_windows": "{{ containerd_sha256_windows_amd64 }}",
"containerd_version": "{{ containerd }}",
"convert_to_template": "true",
"create_snapshot": "false",
"disable_hypervisor": "false",
"disk_size": "40960",
"kubernetes_base_url": "http://{{ host_ip }}:{{ artifacts_container_port }}/artifacts/{{ kubernetes_version }}/bin/windows/amd64",
"kubernetes_series": "{{ kubernetes_series }}",
"kubernetes_semver": "{{ kubernetes_version }}",
"kubernetes_typed_version": "{{ image_version }}",
"load_additional_components": "true",
"netbios_host_name_compatibility": "false",
"nssm_url": "http://{{ host_ip }}:{{ artifacts_container_port }}/artifacts/{{ kubernetes_version }}/bin/windows/amd64/nssm.exe",
"prepull": "false",
"pause_image": "localhost:5000/vmware.io/pause:{{ pause }}",
"runtime": "containerd",
"template": "",
"unattend_timezone": "Pacific Standard Time",
"windows_updates_categories": "",
"windows_updates_kbs": "",
"wins_url": "",
"custom_role": "true",
"custom_role_names": "/image-builder/images/capi/image/ansible-windows",
"ansible_user_vars": "ansible_winrm_operation_timeout_sec=120 ansible_winrm_read_timeout_sec=150 artifacts_container_url=http://{{ host_ip }}:{{ artifacts_container_port }} imageVersion={{ image_version|replace('-', '.') }} registry_store_archive_url=http://{{ host_ip }}:{{ artifacts_container_port }}/artifacts/{{ kubernetes_version }}/registries/{{ registry_store_path }}",
"vmx_version": "21",
"debug_tools": "false",
"enable_auto_kubelet_service_restart": "false",
"windows_admin_password": "VMware123!VMware123!",
"windows_image_index": "3",
"windows_product_key": "VDYBN-27WPP-V4HQT-9VMD4-VMK7H"
}ProductKey section (added):
<UserData>
<AcceptEula>true</AcceptEula>
<FullName>Administrator</FullName>
<Organization>Organization</Organization>
<ProductKey>
<Key>VDYBN-27WPP-V4HQT-9VMD4-VMK7H</Key>
</ProductKey>
</UserData>Image Index section (hardcoded to 2):
<InstallFrom>
<MetaData wcm:action="add">
<Key>/IMAGE/INDEX</Key>
<Value>2</Value>
</MetaData>
</InstallFrom># 1. Verify Docker is running
docker info > /dev/null 2>&1 && echo "Docker OK" || echo "Docker NOT running"
# 2. Check required tools
docker --version
make --version
jq --version
# 3. Verify vCenter connectivity (requires govc)
export GOVC_URL="your-vcenter"
export GOVC_USERNAME="user@domain"
export GOVC_PASSWORD='password'
export GOVC_INSECURE=1
govc about
# 4. Verify ISOs exist on datastore
govc datastore.ls -ds=YourDatastore iso/
# 5. Verify artifacts container is running
docker ps --filter "label=byoi_artifacts"
# 6. Test artifacts endpoint
curl -s http://127.0.0.1:8081/artifacts/metadata/kubernetes_config.json | jq .kubernetes
# 7. Verify ports are available/correct
ss -tlnp | grep -E '8081|8082'# Check container status
docker ps --filter "name=windows-2022-efi"
# View live logs
docker logs -f v1.34.1---vmware.1-windows-2022-efi-image-builder
# Check specific Ansible tasks
docker logs v1.34.1---vmware.1-windows-2022-efi-image-builder 2>&1 | grep "TASK \["
# Check for errors
docker logs v1.34.1---vmware.1-windows-2022-efi-image-builder 2>&1 | grep -i "error\|failed"
# Find the VM in vCenter
govc find . -type m -name "windows-2022-efi-kube*"
# Check VM power state
govc vm.info "windows-2022-efi-kube-v1.34.1-TIMESTAMP"# Check for OVA output
ls -la image-artifacts/ovas/
# Check OVA file size (should be several GB)
du -h image-artifacts/ovas/*.ova
# Check build logs
ls -la image-artifacts/logs/
cat image-artifacts/logs/packer-*.log | tail -100
# Verify container exited successfully (exit code 0)
docker ps -a --filter "name=windows-2022-efi" --format "{{.Names}}: {{.Status}}"| File | Change | Why |
|---|---|---|
packer-variables/vsphere.j2 |
Updated vCenter connection details | Required for your environment |
packer-variables/windows/vsphere-windows.j2 |
Set ISO paths | Point to your uploaded ISOs |
packer-variables/windows/default-args-windows.j2 |
Added windows_product_key |
Required for unattended Windows install |
windows_autounattend.xml |
Added <ProductKey> element |
Required for unattended Windows install |
windows_autounattend.xml |
Changed image index from {{user...}} to 2 |
Packer variable wasn't being replaced |
- During build: ~15GB free recommended
- OVA export: The VMDK is ~26GB uncompressed, OVA is ~5-6GB compressed
- Watch disk usage during export phase - this is when it fails most often
# Check current disk space
df -h /
# Clean Docker (if not using build containers)
docker system prune -f
# Clean apt cache
sudo apt clean
# Trim journal logs
sudo journalctl --vacuum-size=50M
# Remove old snap versions (needs sudo)
sudo snap set system refresh.retain=2
snap list --all | awk '/disabled/{system("sudo snap remove " $1 " --revision=" $3)}'If the build completes but export fails due to "no space left on device":
- The template still exists in vCenter (if
destroy: falseis set) - Export manually from vCenter UI:
- Right-click the template → Export OVF Template
- Save to local machine
- Convert OVF to OVA using ovftool:
cd /path/to/exported/files
ovftool template-name.ovf template-name.ovaexport GOVC_URL="vc-wld01-a.site-a.vcf.lab"
export GOVC_USERNAME="administrator@wld.sso"
export GOVC_PASSWORD="VMware123!VMware123!"
export GOVC_INSECURE=true
# List orphaned build folders
govc datastore.ls -ds=cluster-wld01-01a-vsan01 | grep windows-2022-efi-kube
# Delete them (run for each folder)
govc datastore.rm -ds=cluster-wld01-01a-vsan01 "folder-name"- Kubernetes version v1.31+ - Windows not supported on earlier versions
- ClusterClass builtin-generic-v3.2.0+ - Required for Windows
- VM Class: best-effort-large or larger - Windows needs more resources
- Content Library with Windows OVA uploaded
- Antrea CNI - Required for Windows networking
- In vCenter, navigate to Content Libraries
- Create or select a local library (e.g., "win-ova")
- Click "Actions" → "Import Item"
- Upload the OVA file
- Associate the content library with your namespace
See windows-cluster-ns01.yaml in mydocs folder:
apiVersion: cluster.x-k8s.io/v1beta1
kind: Cluster
metadata:
name: win-cluster01
namespace: ns01
spec:
topology:
class: builtin-generic-v3.2.0
version: v1.34.1+vmware.1-vkr.4
controlPlane:
replicas: 1
metadata:
annotations:
run.tanzu.vmware.com/resolve-os-image: os-name=photon
workers:
machineDeployments:
- class: node-pool
name: linux-pool
replicas: 1
metadata:
annotations:
run.tanzu.vmware.com/resolve-os-image: os-name=photon
- class: node-pool
name: windows-pool
replicas: 1
metadata:
annotations:
run.tanzu.vmware.com/resolve-os-image: os-type=windows
variables:
overrides:
- name: vmClass
value: best-effort-large
variables:
- name: vmClass
value: best-effort-small
- name: storageClass
value: vsan-default-storage-policyNote: Cluster must be v1.31+ with ClusterClass v3.2.0+
kubectl patch cluster guest-cluster01 -n ns01 --type=json -p='[
{
"op": "add",
"path": "/spec/topology/workers/machineDeployments/-",
"value": {
"class": "node-pool",
"name": "windows-pool",
"replicas": 1,
"metadata": {
"annotations": {
"run.tanzu.vmware.com/resolve-os-image": "os-type=windows"
}
},
"variables": {
"overrides": [
{"name": "vmClass", "value": "best-effort-large"}
]
}
}
}
]'When multiple content libraries are associated with a namespace:
metadata:
annotations:
# Specify by name
run.tanzu.vmware.com/resolve-os-image: os-type=windows,content-library=win-ova
# Or by ID
run.tanzu.vmware.com/resolve-os-image: os-type=windows,content-library=cl-xxxxx# Check cluster status
kubectl get cluster -n ns01
# Check machine deployments
kubectl get machinedeployment -n ns01
# Check machines
kubectl get machine -n ns01
# Check Windows node joined
kubectl get nodes -l kubernetes.io/os=windows| File | Description |
|---|---|
vsphere.j2 |
vCenter connection config (destroy: false) |
vsphere-windows.j2 |
Windows ISO paths on datastore |
default-args-windows.j2 |
Build args (8GB RAM, WinRM timeouts, product key) |
windows_autounattend.xml |
Autounattend with ProductKey and image index |
windows-cluster-ns01.yaml |
YAML for new cluster with Windows support |
windows-nodepool-patch.yaml |
Patch for adding Windows nodepool |
SESSION_NOTES.md |
Session notes and lessons learned |
WINDOWS_BUILD_GUIDE.md |
This guide |
Guide created and tested during successful Windows node image build sessions - January 2026