~/2025/07/09/powershell-hyper-v-script-a-failover-cluster-deployment-end-to-end.md

PowerShell: Hyper-V – Script a Failover Cluster Deployment End to End

---
author: 
date: 
read: 5 min
in:   [ps, scripts]
tags: [powershell, hyper-v, cluster]
---

$ grep -n '^#' post.md

I keep coming back to the same complaint about Hyper-V failover clustering: every guide walks you through the wizard, but almost nothing hands you a script you can run twice and trust to produce the same cluster both times. This is the script I use to stand up a new Hyper-V cluster of two or more nodes, from feature installation through validation, quorum, a working Cluster Shared Volume (or a Storage Spaces Direct pool) and a pinned Live Migration network. Every stage checks whether its work is already done, so a run that dies halfway can simply be started again.

Requirements

  • Windows Server 2019 or later on every node, domain joined. Storage Spaces Direct needs Datacenter edition on every node; a SAN-backed cluster does not.
  • Run it from a management server or workstation, not from one of the nodes: the script restarts nodes after installing Hyper-V. Microsoft recommends the management machine run the same Windows version as the nodes (a down-level machine may leave you needing Update-ClusterFunctionalLevel afterwards), with the Hyper-V and Failover Clustering RSAT modules installed.
  • Run it in a local elevated session on that machine, not inside Enter-PSSession. New-Cluster cannot run in a remote session without CredSSP, and the S2D steps are documented as local-session only.
  • Local administrator on every node, and Create Computer Objects on the target OU (or a pre-staged Cluster Name Object), because New-Cluster registers the CNO in Active Directory.
  • WinRM reachable on every node (Enable-PSRemoting if it is not already on).
  • Storage, one of:
    • A shared LUN presented to every node (iSCSI, Fibre Channel or SAS), initialized as GPT and formatted NTFS. Microsoft's CSV guidance is NTFS for SAN volumes; ReFS on a SAN runs the CSV in redirected mode, sending all writes through the coordinator node.
    • Local drives for Storage Spaces Direct: empty (no partitions), identical in count and type on every node, and SSDs with power-loss protection.
  • A free static IP on the management subnet for the cluster name, and, for a two-node cluster, an Azure storage account (Standard general-purpose v2) or an SMB share for the witness.

Parameters

NameTypeRequiredDescription
NodeNamesstringYesHostnames of the Hyper-V hosts to cluster (2 to 64; 2 to 16 with -StorageSpacesDirect).
ClusterNamestringYesNetBIOS name for the Cluster Name Object, 15 characters or fewer.
ClusterIPAddressstringYesStatic IPv4 address for the cluster network name.
SharedDiskNumberintNoNumber of the shared LUN, as Get-ClusterAvailableDisk reports it, to add as a CSV. Omit to skip SAN storage.
StorageSpacesDirectswitchNoValidate with the S2D test set and run Enable-ClusterStorageSpacesDirect instead of adding a SAN disk.
LiveMigrationSubnetstringNoCIDR of the cluster network Live Migration must use, for example 10.10.40.0/24. Every other cluster network is excluded.
MigrationPerformanceOptionstringNoTCPIP, Compression or SMB, applied to every node with Set-VMHost. Omit to leave the default (Compression).
CloudWitnessAccountNamestringNoAzure storage account for a cloud witness.
CloudWitnessAccessKeystringNoAccess key for that storage account (use the primary key the first time).
FileShareWitnessstringNoUNC path of a file share witness, used when no cloud witness is given.
ReportFolderstringNoFolder for the validation report. Defaults to the current directory.
SkipValidationswitchNoSkips Test-Cluster. Only for a lab rebuild of nodes you validated already; production clusters should never skip it.

Usage

Build a two-node SAN-backed cluster with a CSV, a cloud witness and a dedicated Live Migration subnet.

powershell
.\New-HyperVCluster.ps1 -NodeNames 'hv-node01', 'hv-node02' -ClusterName 'hv-clu01' -ClusterIPAddress '10.10.10.50' -SharedDiskNumber 2 -LiveMigrationSubnet '10.10.40.0/24' -CloudWitnessAccountName '<storageaccount>' -CloudWitnessAccessKey '<access-key>' -Verbose

Build a two-node Storage Spaces Direct cluster that uses SMB for Live Migration and a file share witness on a NAS.

powershell
.\New-HyperVCluster.ps1 -NodeNames 'hv-node03', 'hv-node04' -ClusterName 'hv-clu02' -ClusterIPAddress '10.10.10.51' -StorageSpacesDirect -LiveMigrationSubnet '10.10.41.0/24' -MigrationPerformanceOption SMB -FileShareWitness '\\nas01\witness-hv-clu02'

The run ends with a summary object you can paste into the change record.

text
Cluster       : hv-clu01
Nodes         : hv-node01 (Up), hv-node02 (Up)
Quorum        : Cloud Witness
CSVs          : Cluster Disk 1 -> C:\ClusterStorage\Volume1
LiveMigration : Cluster Network 3 (10.10.40.0)
Report        : C:\Build\Validate-hv-clu01-20250709-1402.htm

Script

powershell
<#
.SYNOPSIS
    Builds a Hyper-V failover cluster end to end: features, validation, cluster, quorum, storage and Live Migration network.
.DESCRIPTION
    Installs Hyper-V, Failover Clustering and their PowerShell modules on every node (restarting nodes that
    need it), runs cluster validation, creates the cluster without claiming storage, configures a cloud or
    file share witness, then either adds a shared SAN disk as a Cluster Shared Volume or enables Storage
    Spaces Direct. Finally it restricts Live Migration to one cluster network by setting
    MigrationExcludeNetworks on the Virtual Machine resource type. Each stage checks for existing work, so
    the script can be re-run after a failure.
.PARAMETER NodeNames
    Hostnames of the Hyper-V hosts to cluster (2 to 64; 2 to 16 with -StorageSpacesDirect).
.PARAMETER ClusterName
    NetBIOS name for the Cluster Name Object, 15 characters or fewer.
.PARAMETER ClusterIPAddress
    Static IPv4 address for the cluster network name.
.PARAMETER SharedDiskNumber
    Number of the shared LUN, as Get-ClusterAvailableDisk reports it, to add as a CSV.
.PARAMETER StorageSpacesDirect
    Validate with the S2D test set and run Enable-ClusterStorageSpacesDirect instead of adding a SAN disk.
.PARAMETER LiveMigrationSubnet
    CIDR of the cluster network Live Migration must use. Every other cluster network is excluded.
.PARAMETER MigrationPerformanceOption
    TCPIP, Compression or SMB, applied to every node with Set-VMHost.
.PARAMETER CloudWitnessAccountName
    Azure storage account for a cloud witness.
.PARAMETER CloudWitnessAccessKey
    Access key for that storage account.
.PARAMETER FileShareWitness
    UNC path of a file share witness, used when no cloud witness is given.
.PARAMETER ReportFolder
    Folder for the validation report. Defaults to the current directory.
.PARAMETER SkipValidation
    Skips Test-Cluster. Not recommended outside of a lab rebuild.
.EXAMPLE
    .\New-HyperVCluster.ps1 -NodeNames 'hv-node01', 'hv-node02' -ClusterName 'hv-clu01' -ClusterIPAddress '10.10.10.50' -SharedDiskNumber 2 -CloudWitnessAccountName '<storageaccount>' -CloudWitnessAccessKey '<access-key>'
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.0 (2025-07-09)
    Requires: Windows Server 2019+ nodes, FailoverClusters and Hyper-V modules on the machine running it,
              WinRM to every node, rights to create the cluster computer object
#>

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

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

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

    [Parameter(Mandatory = $false)]
    [int]$SharedDiskNumber,

    [switch]$StorageSpacesDirect,

    [Parameter(Mandatory = $false)]
    [string]$LiveMigrationSubnet,

    [Parameter(Mandatory = $false)]
    [ValidateSet('TCPIP', 'Compression', 'SMB')]
    [string]$MigrationPerformanceOption,

    [Parameter(Mandatory = $false)]
    [string]$CloudWitnessAccountName,

    [Parameter(Mandatory = $false)]
    [string]$CloudWitnessAccessKey,

    [Parameter(Mandatory = $false)]
    [string]$FileShareWitness,

    [Parameter(Mandatory = $false)]
    [string]$ReportFolder = (Get-Location).Path,

    [switch]$SkipValidation
)

$ErrorActionPreference = 'Stop'

# A failover cluster supports up to 64 nodes, but Storage Spaces Direct supports at most 16 servers.
if ($StorageSpacesDirect -and $NodeNames.Count -gt 16) {
    throw "Storage Spaces Direct supports at most 16 nodes; $($NodeNames.Count) were given."
}

$featureNames = @('Hyper-V', 'Failover-Clustering', 'Hyper-V-PowerShell', 'RSAT-Clustering-PowerShell')

# A new cluster name can take a while to resolve in DNS, so talk to the cluster through its first node.
$clusterTarget = $NodeNames[0]

# Stage 1: install roles and features, restarting any node that needs it.
foreach ($node in $NodeNames) {
    Write-Verbose "Checking Hyper-V and clustering features on $node"

    $installResult = Invoke-Command -ComputerName $node -ScriptBlock {
        $missing = Get-WindowsFeature -Name $using:featureNames |
            Where-Object { $_.InstallState -ne 'Installed' }

        if ($missing) {
            Install-WindowsFeature -Name $missing.Name -IncludeManagementTools
        }
    }

    if ($installResult -and "$($installResult.RestartNeeded)" -eq 'Yes') {
        Write-Verbose "Restarting $node to finish the feature installation"
        Restart-Computer -ComputerName $node -Wait -For PowerShell -Timeout 1800 -Force
    }
}

# Stage 2: validate the nodes.
$reportFile = $null

if (-not $SkipValidation) {
    $reportName = Join-Path -Path $ReportFolder -ChildPath ('Validate-{0}-{1:yyyyMMdd-HHmm}' -f $ClusterName, (Get-Date))

    $testParameters = @{
        Node            = $NodeNames
        ReportName      = $reportName
        WarningVariable = 'validationWarnings'
    }

    if ($StorageSpacesDirect) {
        $testParameters['Include'] = 'Storage Spaces Direct', 'Inventory', 'Network', 'System Configuration'
    }

    Write-Verbose "Running Test-Cluster against $($NodeNames -join ', ')"
    $validationOutput = Test-Cluster @testParameters
    $reportFile = $validationOutput | Where-Object { $_ -is [System.IO.FileInfo] } | Select-Object -First 1

    if ($validationWarnings) {
        Write-Warning "Validation returned $($validationWarnings.Count) warning(s). Read $($reportFile.FullName) before you put workloads on this cluster."
    }
}

# Stage 3: create the cluster without letting it claim any disks.
$existingCluster = Get-Cluster -Name $clusterTarget -ErrorAction SilentlyContinue

if (-not $existingCluster) {
    Write-Verbose "Creating cluster $ClusterName at $ClusterIPAddress"
    New-Cluster -Name $ClusterName -Node $NodeNames -StaticAddress $ClusterIPAddress -NoStorage | Out-Null
} elseif ($existingCluster.Name -ne $ClusterName) {
    throw "$clusterTarget already belongs to cluster $($existingCluster.Name), not $ClusterName."
} else {
    Write-Verbose "Cluster $ClusterName already exists, skipping creation"
}

# Stage 4: configure the quorum witness.
if ($CloudWitnessAccountName) {
    Write-Verbose "Configuring a cloud witness in storage account $CloudWitnessAccountName"
    Set-ClusterQuorum -Cluster $clusterTarget -CloudWitness -AccountName $CloudWitnessAccountName -AccessKey $CloudWitnessAccessKey | Out-Null
} elseif ($FileShareWitness) {
    Write-Verbose "Configuring a file share witness at $FileShareWitness"
    Set-ClusterQuorum -Cluster $clusterTarget -FileShareWitness $FileShareWitness | Out-Null
} elseif ($NodeNames.Count -eq 2) {
    Write-Warning 'Two-node cluster with no witness: if either node goes offline, the other cannot keep quorum. Add a cloud or file share witness.'
}

# Stage 5: storage, either Storage Spaces Direct or a shared SAN disk as a CSV.
if ($StorageSpacesDirect) {
    $existingPool = Get-StoragePool -CimSession $clusterTarget -IsPrimordial $false -ErrorAction SilentlyContinue

    if (-not $existingPool) {
        Write-Verbose 'Enabling Storage Spaces Direct'
        Enable-ClusterStorageSpacesDirect -CimSession $clusterTarget -Confirm:$false | Out-Null
    } else {
        Write-Verbose "Storage pool $($existingPool.FriendlyName) already exists, skipping S2D enablement"
    }
} elseif ($PSBoundParameters.ContainsKey('SharedDiskNumber')) {
    $availableDisk = Get-ClusterAvailableDisk -Cluster $clusterTarget |
        Where-Object { $_.Number -eq $SharedDiskNumber }

    if ($availableDisk) {
        Write-Verbose "Adding disk $SharedDiskNumber to the cluster and converting it to a CSV"
        $clusterDisk = $availableDisk | Add-ClusterDisk
        Add-ClusterSharedVolume -Cluster $clusterTarget -Name $clusterDisk.Name | Out-Null
    } else {
        Write-Warning "Disk $SharedDiskNumber is not an available cluster disk. It may already be a CSV, or it is not presented to every node."
    }
}

# Stage 6: restrict Live Migration to one cluster network.
$liveMigrationNetwork = $null

if ($LiveMigrationSubnet) {
    $subnetAddress = ($LiveMigrationSubnet -split '/')[0]
    $clusterNetworks = Get-ClusterNetwork -Cluster $clusterTarget
    $liveMigrationNetwork = $clusterNetworks | Where-Object { $_.Address -eq $subnetAddress }

    if ($liveMigrationNetwork) {
        $excludedIds = ($clusterNetworks | Where-Object { $_.Id -ne $liveMigrationNetwork.Id }).Id -join ';'
        Write-Verbose "Restricting Live Migration to $($liveMigrationNetwork.Name)"

        Get-ClusterResourceType -Cluster $clusterTarget -Name 'Virtual Machine' |
            Set-ClusterParameter -Name MigrationExcludeNetworks -Value $excludedIds
    } else {
        Write-Warning "No cluster network matches $LiveMigrationSubnet. Live Migration keeps the default network order."
    }
}

if ($MigrationPerformanceOption) {
    Write-Verbose "Setting the Live Migration performance option to $MigrationPerformanceOption on every node"
    Set-VMHost -ComputerName $NodeNames -VirtualMachineMigrationPerformanceOption $MigrationPerformanceOption
}

# Summarize the result.
$csvs = Get-ClusterSharedVolume -Cluster $clusterTarget -ErrorAction SilentlyContinue

[PSCustomObject]@{
    Cluster       = (Get-Cluster -Name $clusterTarget).Name
    Nodes         = (Get-ClusterNode -Cluster $clusterTarget | ForEach-Object { "$($_.Name) ($($_.State))" }) -join ', '
    Quorum        = (Get-ClusterQuorum -Cluster $clusterTarget).QuorumResource.Name
    CSVs          = ($csvs | ForEach-Object { "$($_.Name) -> $($_.SharedVolumeInfo.FriendlyVolumeName)" }) -join '; '
    LiveMigration = if ($liveMigrationNetwork) { "$($liveMigrationNetwork.Name) ($($liveMigrationNetwork.Address))" } else { 'Default' }
    Report        = if ($reportFile) { $reportFile.FullName } else { 'Validation skipped' }
}

Notes

  • -NoStorage is intentional. Letting cluster creation claim every eligible disk has burned me before on hosts where a local RAID volume happened to look cluster-eligible; adding the CSV disk explicitly by number avoids that. Microsoft's S2D deployment guide uses -NoStorage for the same reason and then enables S2D as a separate step.
  • Read the validation report even when the script finishes cleanly. Test-Cluster returns the report file plus warnings; a warning-only report still deserves a look before anything production lands on the cluster, and Microsoft's S2D hardware requirements state that the fully configured cluster must pass all validation tests.
  • For S2D, the drives must have no partitions or the pool will not claim them. Microsoft's deployment article has a disk-cleaning script; run it only after triple-checking the server list, because it wipes every non-boot drive.
  • MigrationExcludeNetworks wants cluster network IDs, semicolon separated, no spaces. The Failover Cluster Manager equivalent is Networks > Live Migration Settings. Do not use Set-VMMigrationNetwork for this on a cluster: that cmdlet is for live migration between non-clustered hosts, and the cluster setting is what governs migrations between nodes.
  • Kerberos versus CredSSP only matters for moves the cluster does not broker, such as shared-nothing moves to hosts outside the cluster. Kerberos needs constrained delegation (cifs and Microsoft Virtual System Migration Service) on each host's computer account. Windows Server 2025 enables Credential Guard by default on domain members, which breaks CredSSP-based live migration, so plan on Kerberos constrained delegation there.
  • The cloud witness does not keep your access key: the cluster generates a SAS token from it. When you rotate storage keys, switch every cluster that uses the account to the secondary key first, then regenerate the primary. The nodes need outbound HTTPS (443) to *.core.windows.net, through WinHTTP proxy settings if a proxy is involved.
  • A file share witness can live on a non-domain device (a NAS, or a router with USB storage) from Windows Server 2019 on. Pass -Credential to Set-ClusterQuorum in that case, as Microsoft's Deploy a quorum witness guide shows (Set-ClusterQuorum -Cluster <ClusterName> -FileShareWitness \\server\share -Credential (Get-Credential), with a local account on the device). The Set-ClusterQuorum cmdlet reference doesn't list -Credential among its parameters, so go by the deployment guide for this one. The script's plain call assumes a domain share that grants the CNO Change and Read.

Source