Published: • 22 min read

How to Deploy a SQL Server Availability Group for High Availability

Table of Contents

Despite building dozens of SQL Server availability groups at work, I’ve yet to build one for my homelab. The main reason is that in order to do it right, I needed to set up an Availability Domain, which I have never done before. However, it is currently done, so I was ready to get started.

This post will mainly only cover standard AGs, meaning I won’t be outlining how to create a Contained AG, but I do plan to write one for that specifically at a different time.

Some details about my homelab before I begin.

  • I run a single Proxmox node, managed largely through Terraform.
  • Storage is just a standard ZFS model.
  • I reserved each of the IP addresses for the servers, LB, etc… through my Unifi gateway.
  • Each of the servers I am running in the lab (Two SQL Servers, one AD) are all running on non-licensed Windows Server 2025.
  • My two SQL Servers are both running SQL Server 2025 Enterprise Developer Edition.
  • Very obvious, but of course if my storage or host dies, HA won’t really help me since I’m only on one host, but this is just for my homelab and nothing critical will be stored on them so I am accepting the risk.

Prerequisites

  • A Windows Server Active Directory.
  • Two fresh SQL Server 2025 instances.
  • Ability to reserve at least five IP addresses on your network.
  • Some way to configure DNS, I use AdGuard.

Getting Started

There are a few different ways to configure your AG, but what I am doing is two SQL Server 2025 nodes in synchronous-commit availability group with automatic failover. These will have a listener, and a quorum held by a file-share witness on the domain controller.

My VLAN is as follows

VLAN 192.168.25.0/26:

If you haven’t touched networking outside of what SQL Server makes you deal with, a quick translation: a VLAN is just a logically isolated network segment, in my case one carved out on my router for these five machines. The /26 is the subnet mask written in CIDR notation, and it tells you how many addresses that segment actually has. A /26 gives you 64 addresses (192.168.25.0 through 192.168.25.63), which is plenty for a handful of lab servers. You’ll see this same /26 show up again later when the AG listener gets created, since SQL Server wants that mask spelled out for the IP it binds to.

Role Hostname IP
DC · DNS · Witness DC-01 192.168.25.12
AG Primary SQL25-WIN25-01 192.168.25.13
AG Secondary SQL25-WIN25-02 192.168.25.14
Cluster name object (CNO) SQL25-CLU-01 192.168.25.15
AG Listener SQL25-AG-01L 192.168.25.16

Two of these rows aren’t physical machines. The CNO is a computer object the cluster creates in AD to represent the cluster itself, and the listener is another computer object that represents the AG’s single connection point. Both get their own IP and DNS name, but neither one is a server you log into. More on both below.

The domain is lab.local (NetBIOS LAB). If you’re not used to AD terminology, the domain name (lab.local) is what you’ll use in full commands and connection strings, while the NetBIOS name (LAB) is the short legacy form Windows still uses in places like LAB\svc-sql-engine, meaning the svc-sql-engine account inside the LAB domain.

Every node gets its IP from a DHCP reservation on the Unifi gateway rather than an in-guest static. A reservation just means the router always hands that machine the same address based on its network card’s MAC address, so it behaves like a static IP without you having to configure it inside Windows. I find reservations easier to keep track of in one place, but a normal static IP set inside the guest works exactly the same for everything in this post.

Phase 1 - Deploy and Baseline

First get a SQL baseline captured (run on both instances):

SELECT @@VERSION, SERVERPROPERTY('Edition'), SERVERPROPERTY('EditionID'), SERVERPROPERTY('Collation');
  • Next, validate that both instances are on the same version of SQL Server, ideally Enterprise Edition, but Standard works ok for this lab as well.

Phase 2 - Active Directory and Domain Join

I’ll admit this phase is where I spent most of my time, mostly because I hadn’t stood up a fresh forest, ever. I’ve laid it out below in the order that worked, with some of my notes on stuff that helped me get unstuck.

Stand up the domain controller

First, rename the host and reboot, since a DC’s name can’t easily change after promotion.

Rename-Computer -NewName DC-01 -Force
Restart-Computer -Force

Next, point the DC’s DNS at itself. This is a real “wait, why” moment the first time you hit it: once a machine becomes a domain controller, it also becomes a DNS server, and AD leans on DNS constantly to find things like other domain controllers and services. So the DC needs to ask itself for answers rather than pointing out to your router or ISP like a normal client would. I keep DHCP enabled on the server (the reservation on the Unifi gateway pins the IP anyway), so the only thing that needs to change here is where it resolves names.

$if = (Get-NetAdapter | Where-Object Status -eq 'Up' | Select-Object -First 1).Name
Set-DnsClientServerAddress -InterfaceAlias $if -ServerAddresses 127.0.0.1

Install the AD DS role, then create the forest. Install-ADDSForest is what actually promotes the box to a domain controller, installs DNS, and creates the domain everything else will join. Setting -DomainMode and -ForestMode to Win2025 is fine here since every machine in the lab is already on Server 2025.

Install-WindowsFeature AD-Domain-Services -IncludeManagementTools

Install-ADDSForest `
    -DomainName lab.local -DomainNetbiosName LAB `
    -DomainMode Win2025 -ForestMode Win2025 -InstallDns `
    -SafeModeAdministratorPassword (ConvertTo-SecureString 'YourStrongP@ssword!' -AsPlainText -Force) `
    -Force

The machine reboots on its own after this. Log back in as LAB\Administrator and confirm the forest is actually healthy before moving on.

Get-ADForest | Format-List Name,ForestMode,DomainNamingMaster
Get-Service ADWS,NTDS,DNS,Netlogon | Format-Table Name,Status
Resolve-DnsName lab.local
dcdiag /test:Advertising /test:Services /test:MachineAccount | Select-String 'passed|failed'

DNS forwarding, reverse zone, and time sync

The DC now answers authoritatively for lab.local, meaning it’s the one source of truth for anything under that domain name, but it still needs somewhere to send everything else, like a request to resolve google.com. That’s what a forwarder is: a second DNS server the DC hands off to whenever it gets asked about a name it doesn’t own. I point mine at AdGuard, which is doing my ad blocking and upstream resolution for the rest of the network anyway.

The reverse zone is DNS running backwards. Normal DNS answers “what’s the IP for this name,” a reverse zone answers “what’s the name for this IP.” It sounds like a nice-to-have, but both SQL Server and Windows clustering actually check reverse lookups in a few places, so skipping this zone can cause odd failures later that have nothing obviously to do with DNS.

Last, since this DC is going to be the time source for the rest of the domain, I pointed it at a real NTP pool instead of letting it drift on its own. This matters more than it sounds like it should: Kerberos, the authentication protocol AD and SQL Server both rely on here, rejects requests if the clocks on either side have drifted more than about five minutes apart. When researching, I found clock drift is a surprisingly common cause of logins that fail for no obvious reason.

Set-DnsServerForwarder -IPAddress 192.168.25.53
if (-not (Get-DnsServerZone -Name '0.25.168.192.in-addr.arpa' -EA SilentlyContinue)) {
    Add-DnsServerPrimaryZone -NetworkId '192.168.25.0/26' -ReplicationScope Forest
}
w32tm /config /manualpeerlist:'pool.ntp.org' /syncfromflags:manual /reliable:yes /update
Restart-Service w32time
Resolve-DnsName google.com | Select-Object -First 1

With that done, I went into AdGuard (192.168.25.53) under Settings > DNS > Upstream servers and added a conditional forward, which is a rule that says “if the request is for this specific domain, send it here instead of the normal upstream,” so AdGuard sends anything for my domain back to the DC:

[/lab.local/]192.168.25.12

Organizational units and service accounts

I keep it simple with two OUs, one for the SQL Servers and one for service accounts.

$base = (Get-ADDomain).DistinguishedName
foreach ($ou in 'SQL Servers','Service Accounts') {
    if (-not (Get-ADOrganizationalUnit -Filter "Name -eq '$ou'" -EA SilentlyContinue)) {
        New-ADOrganizationalUnit -Name $ou -Path $base
    }
}

This is the part that actually caused me the most grief. I originally created my service accounts the normal way and then went back to set their Kerberos encryption types afterward. Every time I touched the account again, whether that was a password reset or an attribute change, it blanked the encryption types back out for some reason.

The fix is to set the encryption types in the same New-ADUser call that creates the account, so it’s born correct instead of patched afterward:

$svcOu = "OU=Service Accounts,$base"
$sec   = ConvertTo-SecureString 'YourStrongP@ssword!' -AsPlainText -Force
foreach ($u in 'svc-sql-engine','svc-sql-agent','joinadmin') {
    if (-not (Get-ADUser -Filter "SamAccountName -eq '$u'" -EA SilentlyContinue)) {
        New-ADUser -Name $u -SamAccountName $u -Path $svcOu `
            -UserPrincipalName "[email protected]" `
            -AccountPassword $sec -Enabled $true -PasswordNeverExpires $true `
            -KerberosEncryptionType AES128,AES256
    }
}
Add-ADGroupMember 'Domain Admins' 'joinadmin' -EA SilentlyContinue

joinadmin is only there to join the two nodes to the domain. svc-sql-engine and svc-sql-agent are what the SQL Server services themselves will run under. You can confirm the encryption types took by checking msDS-SupportedEncryptionTypes is 24 (AES128 + AES256) on each account.

Join the SQL nodes to the domain

On each node, rename it and reboot first, since the machine name needs to be final before joining.

$NodeName = 'SQL25-WIN25-01'   # or 'SQL25-WIN25-02' on the second node
Rename-Computer -NewName $NodeName -Force
Restart-Computer -Force

After the reboot, point the node’s DNS at the DC so the domain actually resolves, then join.

$if = (Get-NetAdapter | Where-Object Status -eq 'Up' | Select-Object -First 1).Name
Set-DnsClientServerAddress -InterfaceAlias $if -ServerAddresses 192.168.25.12
Resolve-DnsName lab.local -Server 192.168.25.12

$cred = New-Object System.Management.Automation.PSCredential(
    '[email protected]',
    (ConvertTo-SecureString 'YourStrongP@ssword!' -AsPlainText -Force))
Add-Computer -DomainName lab.local -Credential $cred `
    -OUPath 'OU=SQL Servers,DC=lab,DC=local'
Restart-Computer -Force

If you’re cloning VM templates like I am, make sure the template was properly sysprepped and generalized before you clone it. My first attempt used a template that hadn’t been generalized, so every clone shared the same machine SID, which collided with the domain SID and made a normal domain join fail outright. The workaround at the time was an offline join with djoin, but once I fixed the template, Add-Computer just worked normally. I’m leaving the djoin steps out of this post since they shouldn’t be necessary if your template is set up correctly, but it’s worth knowing that error exists if a live join ever refuses to cooperate.

After the reboot, verify the join actually took.

Get-CimInstance Win32_ComputerSystem | Format-List Name,Domain,PartOfDomain
Test-ComputerSecureChannel -Verbose
nltest /dsgetdc:lab.local

One more wrinkle: joined nodes don’t always self-register their own DNS A record, even on a healthy secure dynamic update zone. An A record is just the DNS entry that maps a hostname to an IP address, normally domain-joined machines create their own automatically, but that self-registration is a background process that occasionally doesn’t land. Since these nodes have fixed IPs anyway, I just created static A records on the DC instead of chasing why dynamic registration didn’t persist.

Add-DnsServerResourceRecordA -ZoneName lab.local -Name SQL25-WIN25-01 -IPv4Address 192.168.25.13
Add-DnsServerResourceRecordA -ZoneName lab.local -Name SQL25-WIN25-02 -IPv4Address 192.168.25.14

Repeat everything in this section on the second node before moving on.

Phase 3 - SQL Server Service Accounts

With both nodes joined, the SQL Server service on each one needs to run under the domain account rather than the local service account it started with. You can do this by hand with WMI, but it’s fiddly to get the Windows-side permissions right, so I just used SQL Server Configuration Manager instead, which handles all of that for you.

On each node:

  1. Open SQL Server Configuration Manager.
  2. Click SQL Server Services in the left pane.
  3. Right-click SQL Server (MSSQLSERVER) and choose Properties.
  4. On the Log On tab, select This account, then enter LAB\svc-sql-engine and its password.
  5. Click OK. Configuration Manager will prompt to restart the service, let it, since the change doesn’t fully take effect until it does.

If you want SQL Server Agent running under the domain identity too, since it isn’t required for anything in this post, only useful if you want scheduled jobs to run as that account, repeat the same steps for SQL Server Agent (MSSQLSERVER) using LAB\svc-sql-agent.

If the engine service refuses to start with a 7038 “SID inconsistent with trust” error right after a fresh domain join, a reboot usually clears it, since the LSA needs to re-read the right that was just granted. If it persists, the node’s secure channel is probably broken and worth repairing with Test-ComputerSecureChannel -Repair. If you ever suspect a stale Kerberos ticket is involved, klist purge from an elevated prompt clears the cache and forces a fresh one on next login.

Last for this phase, align the instance settings, since editions and collation need to match across both nodes before you can build the AG.

sqlcmd -S localhost -E -b -Q "EXEC sys.sp_configure 'show advanced options', 1; RECONFIGURE; EXEC sys.sp_configure 'max server memory (MB)', 6000; RECONFIGURE; EXEC sys.sp_configure 'max degree of parallelism', 4; RECONFIGURE;"
sqlcmd -S localhost -E -Q "SELECT SERVERPROPERTY('Edition') Edition, SERVERPROPERTY('ProductLevel') Patch, SERVERPROPERTY('Collation') Collation;"

Repeat this whole phase on the second node.

Phase 4 - Building the Cluster

Availability groups sit on top of a Windows Server Failover Cluster, so that comes next. A quick note on where each of these commands actually runs, since it isn’t always obvious: installing the feature happens on each node separately, but forming the cluster itself is a single command run once from either node, since it acts on the whole cluster rather than one machine at a time.

On each node, install the failover clustering feature and reboot.

Install-WindowsFeature Failover-Clustering -IncludeManagementTools
Restart-Computer -Force

On the DC, create a file share to act as the witness. Quorum is the cluster’s way of deciding who’s allowed to be in charge when things go wrong, essentially a vote among the nodes plus this witness share, and it exists specifically to prevent “split-brain,” where both nodes think they’re the primary at the same time and start diverging. With only two nodes, a tie is always possible, so the witness acts as a tie-breaking third vote. It’s just a plain SMB file share (a Windows network share, the same kind you’d use to drop a file on another PC), it doesn’t hold any actual data, only a small file the cluster uses to check who currently holds the vote.

New-Item -Path C:\Shares\sqlclu01-fsw -ItemType Directory -Force | Out-Null
New-SmbShare -Name sqlclu01-fsw -Path C:\Shares\sqlclu01-fsw -FullAccess 'LAB\Domain Admins' -EA SilentlyContinue

From either node, validate and then create the cluster. Test-Cluster will throw storage and network warnings since there’s no shared storage in this setup, which is expected, since availability groups don’t use shared storage the way a traditional failover cluster instance would.

Test-Cluster -Node SQL25-WIN25-01,SQL25-WIN25-02
New-Cluster -Name SQL25-CLU-01 -Node SQL25-WIN25-01,SQL25-WIN25-02 `
    -StaticAddress 192.168.25.15 -NoStorage

A word on the CNO, since it was confusing to me when I read about needing to create it. When you create the cluster, Windows creates a computer object in AD for the cluster itself, separate from the two node objects, so the cluster has its own identity on the network with its own name (SQL25-CLU-01) and IP (.15). That identity is what actually authenticates to the witness share, not either node individually, which is why the permission below has to be granted to SQL25-CLU-01$ (the trailing $ is just how Windows denotes a computer account rather than a user account) and not to the node computer accounts. It needs full rights on the witness share, both at the share level and in NTFS, the Windows filesystem permission layer, or setting the quorum will fail.

Grant-SmbShareAccess -Name sqlclu01-fsw -AccountName 'LAB\SQL25-CLU-01$' -AccessRight Full -Force
$cno = 'LAB\SQL25-CLU-01$'
icacls C:\Shares\sqlclu01-fsw /grant ('{0}:(OI)(CI)F' -f $cno)

From either node again, set the quorum to node-and-file-share majority and confirm you’re seeing three votes, one for each node plus the witness.

Set-ClusterQuorum -NodeAndFileShareMajority \\DC-01\sqlclu01-fsw
Get-ClusterNode | Format-Table Name,NodeWeight,State

One last thing to handle on the DC before moving on. The AG listener I’ll add later is, like the CNO, its own computer object in AD with its own name and IP, and by default the CNO doesn’t have permission to create one. If you skip this, adding the listener later fails with a fairly opaque “could not bring the Network Name online” error. Grant it now so that step works when you get there.

$ou = 'OU=SQL Servers,DC=lab,DC=local'
dsacls $ou /I:T /G ('{0}:CC;computer' -f 'LAB\SQL25-CLU-01$')

Phase 5 - Enabling Always On

On both nodes, enable the Always On feature, which ties the instance to the cluster and restarts the SQL service.

Import-Module SqlServer -EA SilentlyContinue
Enable-SqlAlwaysOn -ServerInstance localhost -Force

Worth calling out: a default SQL install often has the TCP/IP protocol disabled in favor of Named Pipes, an older way for processes to talk to each other that works fine on a single machine but was never meant for the kind of network traffic an AG listener needs to serve. Ports are just the numbered “doors” a service listens on; 1433 is SQL Server’s normal client door, and 5022 is the door AG replicas use to talk to each other. AG replication traffic over 5022 still works fine without TCP/IP enabled, but clients, and the AG listener itself on 1433, need TCP enabled. If you miss this, SSMS and the failover wizard will fail with a Named Pipes error rather than anything that points at TCP being off, which is a confusing error to land on if you don’t already know that Named Pipes was even in the running. The New-NetFirewallRule lines after it open those two ports through Windows Firewall, since even with TCP enabled inside SQL Server, the OS firewall will silently drop the traffic unless a rule explicitly allows it.

$inst = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\Instance Names\SQL').MSSQLSERVER
$tcp  = "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\$inst\MSSQLServer\SuperSocketNetLib\Tcp"
Set-ItemProperty $tcp -Name Enabled -Value 1
Set-ItemProperty "$tcp\IPAll" -Name TcpPort -Value 1433
Set-ItemProperty "$tcp\IPAll" -Name TcpDynamicPorts -Value ''

New-NetFirewallRule -DisplayName 'SQL AG - engine 1433'  -Direction Inbound -Protocol TCP -LocalPort 1433 -Action Allow -EA SilentlyContinue | Out-Null
New-NetFirewallRule -DisplayName 'SQL AG - endpoint 5022' -Direction Inbound -Protocol TCP -LocalPort 5022 -Action Allow -EA SilentlyContinue | Out-Null

Restart-Service MSSQLSERVER -Force

On the primary node only, create a test database. It needs to be in full recovery mode with at least one full backup taken before an AG can add it, since automatic seeding needs that starting point to stream from.

New-Item C:\Backups -ItemType Directory -Force | Out-Null
sqlcmd -S localhost -E -C -Q "IF DB_ID('AgDemo') IS NULL CREATE DATABASE AgDemo; GO ALTER DATABASE AgDemo SET RECOVERY FULL; GO BACKUP DATABASE AgDemo TO DISK = N'C:\Backups\AgDemo_full.bak' WITH INIT; BACKUP LOG AgDemo TO DISK = N'C:\Backups\AgDemo_log.trn' WITH INIT;"

Phase 6 - Creating the Availability Group

Since both replicas run under the same domain account, LAB\svc-sql-engine, the AG endpoints can authenticate over Windows/Kerberos without needing certificates at all. Certificate-based endpoints are really only necessary when the replicas run under different accounts.

On both nodes, create a SQL login for the service account (running the service as that account doesn’t automatically create a login for it) and set up the endpoint.

sqlcmd -S localhost -E -C -Q "IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = N'LAB\svc-sql-engine') CREATE LOGIN [LAB\svc-sql-engine] FROM WINDOWS; GO IF NOT EXISTS (SELECT 1 FROM sys.endpoints WHERE name='Hadr_endpoint') CREATE ENDPOINT Hadr_endpoint STATE=STARTED AS TCP (LISTENER_PORT=5022, LISTENER_IP=ALL) FOR DATABASE_MIRRORING (ROLE=ALL); GO GRANT CONNECT ON ENDPOINT::Hadr_endpoint TO [LAB\svc-sql-engine];"

On the primary, create the availability group with automatic seeding so the database streams to the secondary without a manual backup and restore.

CREATE AVAILABILITY GROUP [ag01]
  WITH (AUTOMATED_BACKUP_PREFERENCE = SECONDARY)
  FOR DATABASE [AgDemo]
  REPLICA ON
    N'SQL25-WIN25-01' WITH (
        ENDPOINT_URL = N'TCP://SQL25-WIN25-01.lab.local:5022',
        AVAILABILITY_MODE = SYNCHRONOUS_COMMIT, FAILOVER_MODE = AUTOMATIC,
        SEEDING_MODE = AUTOMATIC, BACKUP_PRIORITY = 50,
        SECONDARY_ROLE (ALLOW_CONNECTIONS = ALL)),
    N'SQL25-WIN25-02' WITH (
        ENDPOINT_URL = N'TCP://SQL25-WIN25-02.lab.local:5022',
        AVAILABILITY_MODE = SYNCHRONOUS_COMMIT, FAILOVER_MODE = AUTOMATIC,
        SEEDING_MODE = AUTOMATIC, BACKUP_PRIORITY = 50,
        SECONDARY_ROLE (ALLOW_CONNECTIONS = ALL));

On the secondary, join the AG and grant it permission to create the database automatically, since without that grant, automatic seeding fails silently rather than throwing an error you’d notice.

ALTER AVAILABILITY GROUP [ag01] JOIN;
ALTER AVAILABILITY GROUP [ag01] GRANT CREATE ANY DATABASE;

Back on the primary, add the listener, the single stable name and IP clients will actually connect to, so they never need to know or care which node is currently primary. The subnet mask has to be spelled out here in its longhand dotted form rather than the /26 shorthand from earlier, they mean the same thing, SQL Server’s syntax just wants it written out. You’ll also want to make sure the listener’s IP is excluded from your DHCP pool (the range of addresses your router hands out automatically) so nothing else gets assigned that address later and conflicts with it.

ALTER AVAILABILITY GROUP [ag01]
  ADD LISTENER N'SQL25-AG-01L' (
    WITH IP ((N'192.168.25.16', N'255.255.255.192')), PORT = 1433);

This is exactly the step the AD permission from Phase 4 was protecting against. Without granting the CNO rights to create computer objects in that OU, this fails with error 19471. If you hit it anyway, you can pre-stage the listener’s computer object manually on the DC and grant the CNO’s SID full control over it before retrying.

Last, confirm both replicas are healthy and connected.

SELECT ar.replica_server_name, hars.role_desc, hars.synchronization_health_desc, hars.connected_state_desc
FROM sys.dm_hadr_availability_replica_states hars
JOIN sys.availability_replicas ar ON hars.replica_id = ar.replica_id
ORDER BY ar.replica_server_name;

Both rows should come back HEALTHY and CONNECTED.

Phase 7 - Proving Failover Works

This is the part I was most looking forward to, since standing up an AG means nothing if you haven’t actually watched it fail over. The listener is the whole point here, clients should always connect through SQL25-AG-01L.lab.local,1433 and never talk to a node directly.

I started a simple heartbeat writer against the listener, inserting one row a second and printing failures, so any gap in timestamps shows exactly how long a test’s downtime was.

$listener = 'SQL25-AG-01L.lab.local'
while ($true) {
    $t = Get-Date -Format 'HH:mm:ss.fff'
    $out = sqlcmd -S $listener -E -C -b -Q "INSERT AgDemo.dbo.Heartbeat DEFAULT VALUES;" 2>&1
    if ($LASTEXITCODE -eq 0) { Write-Host "$t  OK" -ForegroundColor Green }
    else                     { Write-Host "$t  FAIL: $out" -ForegroundColor Red }
    Start-Sleep -Milliseconds 1000
}

With that running in its own window, I ran through a few different tests:

  • Manual failover. From the current secondary, ALTER AVAILABILITY GROUP [ag01] FAILOVER; triggers a graceful role swap. Downtime was only a couple of seconds and there was zero data loss, which makes sense given synchronous commit.
  • Hard primary kill. From the Proxmox host, I hard-stopped the primary’s VM with qm stop, simulating the whole box dying rather than a clean shutdown. Automatic failover kicked in and promoted the secondary, so that was great.
  • Witness loss. Stopping the file share witness on the DC also does not take down the AG, since a two-node cluster still holds quorum on the two node votes alone. It’s just there to break a tie.
  • Split-brain check. Shutting down the secondary, then the primary, then starting only the former secondary back up left the cluster down with just one of three votes, exactly as it should. Node-and-file-share majority refused to auto-start with a minority of votes, which is the whole point of quorum. You can force it back online with Start-ClusterNode -FixQuorum if you’re willing to accept the split-brain risk, but I only did this to see the protection actually hold.

After each test I confirmed the row count only ever went up, never down, which is what you’d expect from a synchronous-commit AG with no data loss on failover.

And “that’s it”…. there are a lot of steps here. I think most shops have a lot more automation that allows for these to be set up much more consistently and faster. Unfortunately I have not set up Ansible for my homelab yet, but the plan is to set this up before my next demo for creating the Contained AG so I don’t need to document all of those steps. Feel free to leave any questions or comments below.