~/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: Tom Lasswell 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-ClusterFunctionalLevelafterwards), 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-Clustercannot 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-Clusterregisters the CNO in Active Directory. - WinRM reachable on every node (
Enable-PSRemotingif 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
| Name | Type | Required | Description |
|---|---|---|---|
NodeNames | string | Yes | Hostnames of the Hyper-V hosts to cluster (2 to 64; 2 to 16 with -StorageSpacesDirect). |
ClusterName | string | Yes | NetBIOS name for the Cluster Name Object, 15 characters or fewer. |
ClusterIPAddress | string | Yes | Static IPv4 address for the cluster network name. |
SharedDiskNumber | int | No | Number of the shared LUN, as Get-ClusterAvailableDisk reports it, to add as a CSV. Omit to skip SAN storage. |
StorageSpacesDirect | switch | No | Validate with the S2D test set and run Enable-ClusterStorageSpacesDirect instead of adding a SAN disk. |
LiveMigrationSubnet | string | No | CIDR of the cluster network Live Migration must use, for example 10.10.40.0/24. Every other cluster network is excluded. |
MigrationPerformanceOption | string | No | TCPIP, Compression or SMB, applied to every node with Set-VMHost. Omit to leave the default (Compression). |
CloudWitnessAccountName | string | No | Azure storage account for a cloud witness. |
CloudWitnessAccessKey | string | No | Access key for that storage account (use the primary key the first time). |
FileShareWitness | string | No | UNC path of a file share witness, used when no cloud witness is given. |
ReportFolder | string | No | Folder for the validation report. Defaults to the current directory. |
SkipValidation | switch | No | Skips 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.
.\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.
.\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.
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
<#
.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
-NoStorageis 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-NoStoragefor the same reason and then enables S2D as a separate step.- Read the validation report even when the script finishes cleanly.
Test-Clusterreturns 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.
MigrationExcludeNetworkswants cluster network IDs, semicolon separated, no spaces. The Failover Cluster Manager equivalent is Networks > Live Migration Settings. Do not useSet-VMMigrationNetworkfor 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 (
cifsandMicrosoft 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
-CredentialtoSet-ClusterQuorumin 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). TheSet-ClusterQuorumcmdlet reference doesn't list-Credentialamong 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
- Test-Cluster, New-Cluster and Set-ClusterQuorum cmdlet references
- Deploy a quorum witness for a failover cluster
- Deploy Storage Spaces Direct on Windows Server
- Use Cluster Shared Volumes in a failover cluster
- Control Live Migration SMB bandwidth cluster-wide (Live Migration network selection with
MigrationExcludeNetworks) - Set up hosts for live migration without Failover Clustering (authentication options and the Windows Server 2025 CredSSP change)