One of the features that kept me using the Vagrant VirtualBox provider for a long time on Windows rather than the native HyperV hypervisor was the ability to set static IP addresses for your VMs. But it turns out with a bit of extra effort you can get this working with the HyperV Vagrant provider too.

At a high level, the solution is create a second virtual switch of type “Internal”, attach that switch to the VM as a network adapter, and then script the setting up a static IP from the range defined for the internal switch.

The tricky part of this process is adding support for this to work on both Linux and Windows, as the triggers for running these customizations are different between these two platforms.

Triggers

The following sections outline the details of how I implemented these customizations to my HyperV VMs using Vagrant triggers. Triggers are life-cycle hooks in the provisioning of a VM.

Shared Triggers

Creating the Virtual Switch can be done at the same time regardless of platform:

config.trigger.before :up do |trigger|
    trigger.info = "Creating Hyper-V switch if it does not exist..."
    trigger.run = {privileged: "true", powershell_elevated_interactive: "true", path: "../scripts/create-nat-hyperv-switch.ps1"}
end

I followed the guide here to write this snippet of Powershell.

If ("NATSwitch" -in (Get-VMSwitch | Select-Object -ExpandProperty Name) -eq $FALSE) {
    'Creating Internal-only switch named "NATSwitch" on Windows Hyper-V host...'

    New-VMSwitch -SwitchName "NATSwitch" -SwitchType Internal
    New-NetIPAddress -IPAddress 192.168.0.1 -PrefixLength 24 -InterfaceAlias "vEthernet (NATSwitch)"
    New-NetNAT -Name "NATNetwork" -InternalIPInterfaceAddressPrefix 192.168.0.0/24
} else {
    '"NATSwitch" for static IP configuration already exists; skipping'
}

If ("192.168.0.1" -in (Get-NetIPAddress | Select-Object -ExpandProperty IPAddress) -eq $FALSE) {
    'Registering new IP address 192.168.0.1 on Windows Hyper-V host...'

    New-NetIPAddress -IPAddress 192.168.0.1 -PrefixLength 24 -InterfaceAlias "vEthernet (NATSwitch)"
} else {
    '"192.168.0.1" for static IP configuration already registered; skipping'
}

If ("192.168.0.0/24" -in (Get-NetNAT | Select-Object -ExpandProperty InternalIPInterfaceAddressPrefix) -eq $FALSE) {
    'Registering new NAT adapter for 192.168.0.0/24 on Windows Hyper-V host...'

    New-NetNAT -Name "NATNetwork" -InternalIPInterfaceAddressPrefix 192.168.0.0/24
} else {
    '"192.168.0.0/24" for static IP configuration already registered; skipping'
}

Triggers for Linux (Rocky8)

Attaching the Switch to the VM

In this trigger and script you can see I use the current working directory name as an argument. I also use the current working directory name to prefix the VM name.

config.trigger.before :"VagrantPlugins::HyperV::Action::StartInstance" do |trigger|
    trigger.info = "Setting Hyper-V switch to 'NATSwitch' to allow for static IP..."
    trigger.run = {privileged: "true", powershell_elevated_interactive: "true", path: "../scripts/set-hyperv-switch.ps1", args: File.basename(Dir.getwd)}
end

Where the script contents looks like:

$netAdapter = Get-VM "$($args[0])*" | Get-VMNetworkAdapter -Name NAT -ErrorAction SilentlyContinue

if ($null -ne $netAdapter) {
    Write-Host "The network adapter already exists."
} else {
    Write-Host "The network adapter does not exist, creating..."
    Get-VM "$($args[0])*" | Add-VMNetworkAdapter -Name NAT -SwitchName NATSwitch
}

Triggers for Windows (Windows 2019)

Note I first have to sleep to allow some initial boot up operations to finish before I can attach the switch.

config.trigger.before :"VagrantPlugins::HyperV::Action::WaitForIPAddress", type: :action do |t|
  t.info = "Sleep to prevent issues where Windows hasn't completed Applying computer settings"
  t.run = { inline: "Start-Sleep -Seconds 120" }
end

config.trigger.before :"VagrantPlugins::HyperV::Action::WaitForIPAddress", type: :action do |t|
  t.name = "Add NATSwitch to VM"
  t.run = {
    path: "./../scripts/set-hyperv-switch.ps1",
    args: [File.basename(Dir.getwd)]
  }
end

Assign the Static IP for Linux (Rocky8)

config.vm.provision "shell", path: "./../scripts/configure-static-ip.sh", args: [IP_ADDRESS]
#!/bin/sh

echo "Setting static IP $1 address for Hyper-V..."

cat << EOF > /etc/sysconfig/network-scripts/ifcfg-eth1
DEVICE=eth1
BOOTPROTO=none
ONBOOT=yes
PREFIX=24
IPADDR=$1
GATEWAY=192.168.0.1
DNS1=8.8.8.8
EOF

Assign the Static IP for Windows (Windows 2019)

config.vm.provision "shell", path: "./../scripts/configure-static-ip.ps1", args: [IP_ADDRESS]
$adapter = Get-NetAdapter -Physical | Where-Object { $_.Name -eq "Ethernet 2" }

Set-NetIPInterface -InterfaceIndex $adapter.InterfaceIndex -Dhcp Disabled
New-NetIPAddress -InterfaceIndex $adapter.InterfaceIndex -IPAddress $($args[0]) -PrefixLength 24 -DefaultGateway "192.168.0.1"