~/2026/06/03/hyper-v-deploying-a-two-node-failover-cluster-on-a-budget.md

Hyper-V: Deploying a Two-Node Failover Cluster on a Budget

---
author: 
date: 
read: 6 min
in:   [engineering]
tags: [hyper-v, cluster]
---

$ grep -n '^#' post.md

Every failover cluster design guide I read early on assumed a shared SAN, a fibre channel fabric, and a budget to match. Most of the small and mid-size environments I have supported have none of those things and still need two Hyper-V hosts that can survive one of them dying mid-shift. Here is how I have gotten a real two-node cluster onto hardware that would make a SAN vendor wince, with the Microsoft requirements that decide what "budget" is allowed to mean, and the scripts I use to build it and then try to break it.

Storage Spaces Direct Instead of a SAN

The single biggest budget lever is skipping shared storage entirely. Storage Spaces Direct (S2D) pools drives that are physically attached to one server each and mirrors data between the servers over the network, so the SAN, the fibre channel switches and the HBAs all disappear from the bill. The catch that surprises people pricing it: S2D needs Windows Server Datacenter edition on every node. Standard edition will not do it, so compare the Datacenter licences against the SAN you are not buying, not against nothing.

What Microsoft documents as the floor for a two-node build:

  • Two to 16 servers, ideally the same make and model. Two is a supported number, not a hack.
  • At least four capacity drives per server (all-flash or all-NVMe), plus at least two cache drives per server if you mix media types, with the same number and type of drives in every server.
  • 4 GB of RAM per terabyte of cache drive capacity on each server, on top of what the VMs need, for S2D metadata.
  • A 10 Gbps NIC or faster for a two- or three-node cluster, with two or more connections per node recommended. RDMA (iWARP or RoCE) and 25 Gbps are the recommendation for four nodes and up, and the deployment guide recommends RDMA in general.

For a two-node cluster, a classic two-way mirror costs you half your raw capacity. Windows Server 2019 added nested resiliency, which exists only for exactly two nodes: a nested two-way mirror keeps two copies on each server (effectively a four-way mirror), so the volume survives a server down and a drive failing on the survivor at the same time. The price is capacity efficiency: 25% for nested two-way mirror, or roughly 35-40% for nested mirror-accelerated parity, depending on drive count and the mirror/parity split. Resiliency is chosen per volume, so I put the VMs I cannot lose on nested volumes and scratch or test workloads on classic mirror. You cannot convert a volume between resiliency types later, so decide before the first VM lands.

The interconnect matters more than people expect. Microsoft supports switchless interconnects, where every node has a direct connection to every other node. With two nodes that is just a pair of cables between two NICs, which removes the storage switch from the bill and is the cheapest supported topology. I have built more than one two-node cluster this way precisely because there is no third node that would need a switch to reach everyone else.

The Witness Is Not Optional, and It Is Nearly Free

A two-node cluster without a quorum witness is a coin flip waiting to happen. Microsoft's S2D guide is blunt about it: a two-server deployment requires a witness, otherwise if either server goes offline the other becomes unavailable. You have three realistic choices:

  • Cloud witness: a blob in an Azure Standard general-purpose v2 storage account (LRS if the cluster is on-premises), reached over HTTPS on port 443. The cluster creates an msft-cloud-witness container and can share one account across many clusters. Small branch-office clusters, including two-node ones, are an explicitly supported scenario, and there is no third machine on site.
  • File share witness: an SMB 2+ share with at least 5 MB free, dedicated to one cluster. From Windows Server 2019 it can sit on a non-domain device, including a router with local USB storage, which is the no-internet, no-third-server option.
  • Disk witness: needs shared storage, which an S2D cluster by definition does not have.

I have stopped recommending a file share witness on a physical server in the same rack, and Microsoft's guidance agrees: the share should be physically separate from the nodes, down to network, power, rack or room. A witness that goes down with the same power event as your two nodes protects you against nothing. When the site has reliable outbound internet, cloud witness is my default.

Used Enterprise Gear Beats New Consumer Gear

Budget builds tempt people toward consumer NVMe drives and desktop-class NICs, and I understand the instinct when the alternative is an eye-watering enterprise SKU. Microsoft's hardware requirements settle the argument for drives: solid-state drives must provide power-loss protection, and cache drives are recommended at 3 drive-writes-per-day or more. Components need Windows Server Catalog certification for your OS version, and Microsoft recommends the Software-Defined Data Center (SDDC) Standard or Premium qualifications for servers and NICs. Drives have to reach Windows directly: a SAS HBA in pass-through, or direct-attached SATA or NVMe. A RAID controller that cannot pass physical devices through is not supported.

So the better budget move, in my experience, has been sourcing recently retired enterprise gear: a generation-old server with real enterprise NVMe that has power-loss protection, an HBA instead of a RAID card, and a NIC that appears in the catalog. It is still a fraction of the cost of a comparable current-generation SAN-attached build, and it behaves the way the clustering documentation assumes hardware behaves.

Build It From a Script

I use the general-purpose cluster deployment script for most builds. For a two-node S2D cluster I also run the version below, which adds the pre-flight checks that matter at this size (edition, drive count, drive symmetry) and creates nested-resiliency volumes at the end. Run it from a management machine on the same Windows version as the nodes, in a local elevated session, after the nodes have Hyper-V and Failover Clustering installed and their drives have been wiped of old partitions. Tier creation follows Microsoft's nested resiliency steps: Windows Server 2019 needs the tier templates created once, and on 2022 the script only creates them if they are missing.

powershell
<#
.SYNOPSIS
    Builds a two-node Storage Spaces Direct Hyper-V cluster with a cloud witness and nested two-way mirror volumes.
.DESCRIPTION
    Checks that both nodes run Datacenter edition and have the same number (four or more) and media types
    of poolable drives, validates with the Storage Spaces Direct test set, creates the cluster without storage,
    configures a cloud witness, enables S2D and creates nested two-way mirror CSV volumes. Prints the
    resulting virtual disks and quorum configuration.
.PARAMETER NodeNames
    The two cluster nodes.
.PARAMETER ClusterName
    NetBIOS name for the cluster, 15 characters or fewer.
.PARAMETER ClusterIPAddress
    Static IPv4 address for the cluster name.
.PARAMETER WitnessAccountName
    Azure storage account (Standard general-purpose v2) for the cloud witness.
.PARAMETER WitnessAccessKey
    Primary access key for that storage account.
.PARAMETER CapacityMediaType
    Media type of the capacity drives: SSD or HDD.
.PARAMETER VolumeNames
    Names of the nested two-way mirror volumes to create.
.PARAMETER VolumeSize
    Size of each volume.
.EXAMPLE
    .\New-TwoNodeS2DCluster.ps1 -NodeNames 'hv-node01', 'hv-node02' -ClusterName 'hv-clu01' -ClusterIPAddress '10.10.10.50' -WitnessAccountName '<storageaccount>' -WitnessAccessKey '<access-key>'
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.0 (2026-06-03)
    Requires: Windows Server 2019+ Datacenter nodes, FailoverClusters and Storage modules, WinRM
#>

[CmdletBinding()]
param (
    [Parameter(Mandatory = $true)]
    [ValidateCount(2, 2)]
    [string[]]$NodeNames,

    [Parameter(Mandatory = $true)]
    [ValidateLength(1, 15)]
    [string]$ClusterName,

    [Parameter(Mandatory = $true)]
    [string]$ClusterIPAddress,

    [Parameter(Mandatory = $true)]
    [string]$WitnessAccountName,

    [Parameter(Mandatory = $true)]
    [string]$WitnessAccessKey,

    [Parameter(Mandatory = $false)]
    [ValidateSet('SSD', 'HDD')]
    [string]$CapacityMediaType = 'SSD',

    [Parameter(Mandatory = $false)]
    [string[]]$VolumeNames = @('Volume01'),

    [Parameter(Mandatory = $false)]
    [UInt64]$VolumeSize = 1TB
)

$ErrorActionPreference = 'Stop'
$firstNode = $NodeNames[0]

# Pre-flight: edition and drive symmetry.
$inventory = Invoke-Command -ComputerName $NodeNames -ScriptBlock {
    $operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem
    $poolable = @(Get-PhysicalDisk -CanPool $true)

    [PSCustomObject]@{
        Node          = $env:COMPUTERNAME
        Edition       = $operatingSystem.Caption
        PoolableDisks = $poolable.Count
        MediaTypes    = ($poolable | Group-Object -Property MediaType | Sort-Object -Property Name | ForEach-Object { "$($_.Name) x$($_.Count)" }) -join ', '
    }
}

$inventory | Format-Table -Property Node, Edition, PoolableDisks, MediaTypes -AutoSize

if ($inventory | Where-Object { $_.Edition -notmatch 'Datacenter' }) {
    throw 'Storage Spaces Direct requires Windows Server Datacenter edition on every node.'
}

if (@($inventory.MediaTypes | Sort-Object -Unique).Count -ne 1) {
    throw 'The nodes have different numbers or types of poolable drives. S2D expects the same number and type in every server.'
}

if ($inventory | Where-Object { $_.PoolableDisks -lt 4 }) {
    throw 'Fewer than four poolable drives on a node. Windows Server needs at least four capacity drives per server.'
}

# Validate with the S2D test set, as the Microsoft deployment guide does.
$report = Test-Cluster -Node $NodeNames -Include 'Storage Spaces Direct', 'Inventory', 'Network', 'System Configuration'
Write-Output "Validation report: $(($report | Where-Object { $_ -is [System.IO.FileInfo] }).FullName)"

# Create the cluster without storage, then the witness, then S2D.
if (-not (Get-Cluster -Name $firstNode -ErrorAction SilentlyContinue)) {
    New-Cluster -Name $ClusterName -Node $NodeNames -StaticAddress $ClusterIPAddress -NoStorage | Out-Null
}

Set-ClusterQuorum -Cluster $firstNode -CloudWitness -AccountName $WitnessAccountName -AccessKey $WitnessAccessKey | Out-Null

if (-not (Get-StoragePool -CimSession $firstNode -IsPrimordial $false -ErrorAction SilentlyContinue)) {
    Enable-ClusterStorageSpacesDirect -CimSession $firstNode -Confirm:$false | Out-Null
}

# Nested two-way mirror tier template (required on Windows Server 2019, harmless if already present).
$mirrorTier = "NestedMirrorOn$CapacityMediaType"

if (-not (Get-StorageTier -CimSession $firstNode -FriendlyName $mirrorTier -ErrorAction SilentlyContinue)) {
    New-StorageTier -CimSession $firstNode -StoragePoolFriendlyName 'S2D*' -FriendlyName $mirrorTier -ResiliencySettingName Mirror -MediaType $CapacityMediaType -NumberOfDataCopies 4 | Out-Null
}

# With HDD capacity (storage bus cache), stop caching writes once a node has been down for 30 minutes.
if ($CapacityMediaType -eq 'HDD') {
    Get-StorageSubSystem -CimSession $firstNode -FriendlyName 'Cluster*' |
        Set-StorageHealthSetting -Name 'System.Storage.NestedResiliency.DisableWriteCacheOnNodeDown.Enabled' -Value 'True'
}

# New-Volume on S2D creates the virtual disk, formats it and adds it to Cluster Shared Volumes.
foreach ($volumeName in $VolumeNames) {
    if (-not (Get-VirtualDisk -CimSession $firstNode -FriendlyName $volumeName -ErrorAction SilentlyContinue)) {
        New-Volume -CimSession $firstNode -StoragePoolFriendlyName 'S2D*' -FriendlyName $volumeName -FileSystem CSVFS_ReFS -StorageTierFriendlyNames $mirrorTier -StorageTierSizes $VolumeSize | Out-Null
    }
}

Get-VirtualDisk -CimSession $firstNode |
    Format-Table -Property FriendlyName, ResiliencySettingName, OperationalStatus, HealthStatus, Size -AutoSize

Get-ClusterQuorum -Cluster $firstNode | Format-List -Property Cluster, QuorumResource

Test the Failure You Are Actually Buying Insurance Against

The point of a two-node cluster is surviving the loss of one node, so that is the test that matters before you call the build done. I run it twice. First a planned drain, which proves live migration and storage resync behave; Microsoft's maintenance procedure is to confirm every volume is Healthy/OK, Suspend-ClusterNode -Drain (on Windows Server 2019, also Enable-StorageMaintenanceMode on the node's disks), do the work, take the disks out of maintenance mode on 2019, Resume-ClusterNode -Failback Immediate, then wait for Get-StorageJob to come back empty before touching the other node. Then the unplanned one: pull power on a host without warning and time how long the VMs take to come back on the survivor. I have seen budget builds pass every health check in Failover Cluster Manager and then take minutes to fail over in practice because the interconnect could not keep up with resynchronization traffic after a rebuild. Run the test before the cluster goes anywhere near production, not after the first real outage tells you the answer.

This script times both halves. It refuses to start unless every volume is healthy, drains and restarts one node, handling storage maintenance mode itself on Windows Server 2019 (or, with -Unplanned, has you pull its power), measures how long until every clustered role is online on the survivor, and then measures how long storage takes to resync.

powershell
<#
.SYNOPSIS
    Times a planned drain or an unplanned node loss on a two-node S2D cluster, then times the storage resync.
.DESCRIPTION
    Refuses to start unless every virtual disk is Healthy. In planned mode it drains the target node,
    restarts it and resumes it with immediate failback; on Windows Server 2019 it puts the node's disks in
    storage maintenance mode before the restart and takes them out before the resume (2022 and later do this
    automatically). In unplanned mode the clock starts when the operator presses Enter, just before pulling
    power on the target node. It records how long until every clustered role is online on a surviving node,
    then how long until Get-StorageJob reports no repair jobs and every virtual disk is Healthy again.
.PARAMETER ClusterName
    The cluster to test.
.PARAMETER NodeName
    The node to take down.
.PARAMETER Unplanned
    Wait for the operator to cut power instead of draining and restarting the node.
.PARAMETER TimeoutMinutes
    How long to wait for each phase before giving up.
.EXAMPLE
    .\Test-TwoNodeFailover.ps1 -ClusterName 'hv-clu01' -NodeName 'hv-node02' -Unplanned
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.0 (2026-06-03)
    Requires: FailoverClusters and Storage modules; run from a machine that is not in the cluster
#>

[CmdletBinding()]
param (
    [Parameter(Mandatory = $true)]
    [string]$ClusterName,

    [Parameter(Mandatory = $true)]
    [string]$NodeName,

    [switch]$Unplanned,

    [Parameter(Mandatory = $false)]
    [int]$TimeoutMinutes = 60
)

$ErrorActionPreference = 'Stop'
$survivor = (Get-ClusterNode -Cluster $ClusterName | Where-Object { $_.Name -ne $NodeName }).Name

$unhealthy = Get-VirtualDisk -CimSession $survivor | Where-Object { $_.HealthStatus -ne 'Healthy' }

if ($unhealthy) {
    throw "Not safe to take a node down: $($unhealthy.FriendlyName -join ', ') not Healthy."
}

# Windows Server 2019 (build 17763) needs storage maintenance mode set by hand; 2022 (build 20348) and later do it on drain.
$manualStorageMaintenance = [int](Get-CimInstance -ClassName Win32_OperatingSystem -ComputerName $NodeName).BuildNumber -lt 20348

$roles = Get-ClusterGroup -Cluster $survivor | Where-Object { $_.Name -notin 'Cluster Group', 'Available Storage' }
Write-Output "Testing $($roles.Count) clustered roles; $NodeName goes down, $survivor survives"

$clock = [System.Diagnostics.Stopwatch]::StartNew()

if ($Unplanned) {
    Read-Host "Press Enter, then pull power on $NodeName immediately"
    $clock.Restart()
} else {
    Suspend-ClusterNode -Cluster $survivor -Name $NodeName -Drain -Wait | Out-Null
    Write-Output "Drain finished after $([int]$clock.Elapsed.TotalSeconds)s"

    if ($manualStorageMaintenance) {
        Get-StorageScaleUnit -CimSession $survivor -FriendlyName $NodeName | Enable-StorageMaintenanceMode
    }

    Restart-Computer -ComputerName $NodeName -Force
}

# Phase 1: every role online on the survivor.
do {
    Start-Sleep -Seconds 5
    $pending = Get-ClusterGroup -Cluster $survivor |
        Where-Object { $_.Name -notin 'Cluster Group', 'Available Storage' } |
        Where-Object { $_.State -ne 'Online' -or $_.OwnerNode.Name -ne $survivor }
} while ($pending -and $clock.Elapsed.TotalMinutes -lt $TimeoutMinutes)

Write-Output "Roles online on $survivor after $([int]$clock.Elapsed.TotalSeconds)s; still pending: $(@($pending).Count)"

# Wait for the node to come back before measuring resync.
Read-Host "Press Enter once $NodeName is powered on and shows Up or Paused in Get-ClusterNode"

if (-not $Unplanned) {
    if ($manualStorageMaintenance) {
        Get-StorageScaleUnit -CimSession $survivor -FriendlyName $NodeName | Disable-StorageMaintenanceMode
    }

    Resume-ClusterNode -Cluster $survivor -Name $NodeName -Failback Immediate | Out-Null
}

# Phase 2: storage resync.
$clock.Restart()

do {
    Start-Sleep -Seconds 30
    $jobs = @(Get-StorageJob -CimSession $survivor | Where-Object { $_.JobState -eq 'Running' })
    $degraded = @(Get-VirtualDisk -CimSession $survivor | Where-Object { $_.HealthStatus -ne 'Healthy' })
    Write-Verbose "$($jobs.Count) repair job(s) running, $($degraded.Count) volume(s) not Healthy"
} while (($jobs.Count -gt 0 -or $degraded.Count -gt 0) -and $clock.Elapsed.TotalMinutes -lt $TimeoutMinutes)

Write-Output "Storage resync finished after $([int]$clock.Elapsed.TotalMinutes) minutes"

While resync runs, Get-VirtualDisk shows volumes as InService/Warning, which Microsoft documents as normal. The number to write down is the second one: until resync finishes, the cluster cannot lose the other node, so the resync time is your real window of exposure after any reboot, including every Patch Tuesday.

The Real Budget Win Is Fewer Moving Parts

A two-node S2D cluster with a cloud witness has fewer components to fail than a SAN-based equivalent: no fibre switches, no separate storage controller firmware to patch, no HBA driver compatibility matrix to track. The budget saving is not just the purchase price, it is the years of maintenance overhead that never gets billed anywhere but shows up as engineer time regardless.

References