
Confession time. After the Nextcloud migration battle log, a double-digit number of playbook runs, one nftables ruleset that cheerfully flushed K3s’ own tables, and more YAML than any human should read in a single week, I had officially had it up to here with Nextcloud, Ansible and anything containing the word “migration”.
So I did what any reasonable Linux nerd does when burned out on Linux: I went over to the other side. Three servers, three fresh installs of Windows Server 2025, and a few evenings of Active Directory, DNS, DHCP, Group Policy, Storage Spaces and BitLocker. No YAML. Just a lot of Get- and Set-.
To be clear, this isn’t a conversion story. I’m running two tracks now: Linux stays where it is and keeps doing the heavy lifting, while privately I want to properly learn the Microsoft side, and specifically the server. Not “click Next until it works”. Understanding what a domain controller does when it boots, why DHCP failover has the shape it has, and what a Group Policy actually writes to the registry. The homelab is the classroom.
Or, less formally: I simply want to play around with a real Windows infrastructure for a while. Domain, policies, file services, patching, encryption, virtualization, the whole package, with enough machines that it behaves like a small company and not like a single test VM that I’ll delete after an hour.
🖥️ The lineup
Three HP boxes, all with iLO, all now running Windows Server 2025, all promoted to domain controllers in the same forest:
- HP1: domain controller, PDC emulator, DNS, DHCP
- HP2: domain controller, DNS, DHCP (failover partner of HP1)
- HP3: domain controller, DNS, plus three SSDs in a Storage Spaces pool, and the future central file server of the house
The domain is muench.home.arpa. home.arpa is the zone RFC 8375 reserves for exactly this: residential networks. No more fake .local fighting with mDNS, and no registering a public domain just to name a lab.
Three DCs for a household is objectively overkill. That’s the point. With three, I can take one down, reboot it, or break it, and still watch the other two carry on. As it turned out, that’s exactly what I got to observe (more below).
And yes, the old habits came along. I’m not clicking through Server Manager on three machines. Everything is driven remotely over WinRM from my Linux jump box with pywinrm. Old habits die hard, but at least they now speak PowerShell.
First gotcha of the trip: WinRM ships the script as base64 on the command line, and Windows caps a command line at 8191 characters. Anything longer doesn’t fail. It just silently does nothing. The workaround: upload scripts in small chunks, verify a SHA256 on the target, then execute. Very Windows, very educational.
Second gotcha: I turned on the OpenSSH server as a manual fallback, and port 22 timed out from outside even though the service was running and the firewall rule said “enabled”. The auto-created rule only covered the Private profile. A domain-joined server runs the Domain profile. One Set-NetFirewallRule -Profile Domain,Private later, it worked.
🤖 My co-admin is an AI (and no, this isn’t “vibe admin”)
Full disclosure about that WinRM pipeline: I’m not the only one using it. When I get stuck, Claude (Anthropic’s AI, running as Claude Code on the Linux jump box) connects to the servers through exactly that WinRM interface and helps with the configuration. It reads the current state, suggests changes, explains them, makes them after I say yes, and then checks the result.
How that works in practice, since there’s no magic involved: Claude runs in a terminal on the Linux box and can execute shell commands there. For quick one-liners it calls the servers directly through pywinrm. For anything bigger, the workflow looks like this:
1. write a PowerShell script locally (check_hp2.ps1, gpo_bitlocker.ps1, …)
2. open a WinRM session to the server (HTTP 5985, NTLM, message-encrypted)
3. upload the script in ~1200-char base64 chunks → C:\Temp\
4. compare SHA256 locally vs. on the server → abort if they differ
5. execute it, collect stdout + errors (unwrapping PowerShell's CLIXML error stream)
6. read the output, decide the next step → back to 1The chunked upload and the checksum are there because of the 8191-character limit from the gotcha above: a script that gets truncated in transit is worse than one that doesn’t arrive at all. The scripts are ordinary .ps1 files, so I can read every one of them before or after it runs. That’s also the nice side effect: at the end of a session I’m left with a pile of readable PowerShell that shows exactly what was done, and I can learn from it.
You’ve probably heard the term vibe coding. Andrej Karpathy coined it in early 2025 for a style of programming where you describe what you want in plain language, let the AI write the code, and more or less accept whatever comes out without reading it closely. It’s about code, and the “vibe” part is precisely the not-checking.
What I’m doing here is the admin version of the first half, but deliberately not the second half. Letting an AI fire unreviewed commands at your domain controllers would be “vibe admin”, and that’s a great way to meet your backup strategy up close. So the ground rules are:
- Read first, change second. Every task starts with a read-only inventory of what’s actually configured, not what we think is configured.
- I decide, it executes. Changes happen after I agree, and anything destructive gets an explicit question first.
- Verify from the other side. A GPO isn’t done when
New-GPOreturns. It’s done when the registry value shows up on the target machine. DHCP failover isn’t fixed until both partners report the new setting. - Explain the why. I want to learn Windows Server, not outsource it. So I ask a lot of “why is it like that?” questions, and some of the best parts of this post came out of those answers.
A real example from the session I wrote this post in: we wanted to enable automatic takeover in the DHCP failover relationship. The cmdlet on HP1 answered with a plain PermissionDenied, despite domain admin rights. Claude recognized the pattern: the classic double hop. I was logged on to HP1 remotely, the cmdlet has to change the setting on HP2 at the same time, and HP1 isn’t allowed to pass my credentials on to a third machine. The fix was a one-time scheduled task that runs locally on HP1 with a proper logon, and gets deleted again afterwards. Then we checked the setting on both partners. That’s something I would have googled for an hour. Instead I got a ten-minute lesson on how credential delegation works in Windows.
It’s not a replacement for understanding. The AI is fast and knows a lot, but it also makes mistakes, and on this trip it overwrote a PowerShell variable because it forgot they’re case-insensitive. It’s more like pair programming with a colleague who has read every Microsoft Learn article ever written, never gets tired of questions, and whose work you still review.
🌐 DNS: the part AD can’t live without
Active Directory is, at its core, a very opinionated consumer of DNS. Clients find their domain controllers through SRV records (_ldap._tcp.dc._msdcs...), so if DNS is wrong, everything is wrong, usually in confusing ways. The ancient sysadmin haiku applies: It’s not DNS. / There’s no way it’s DNS. / It was DNS.
All three DCs run AD-integrated DNS, so the zones replicate with the directory itself instead of via zone transfers. All three are also global catalogs, and DHCP hands out all three as DNS servers, so any single one can disappear without clients noticing:
PS> Get-DhcpServerv4OptionValue -ScopeId $scope -OptionId 6
OptionId Name Type Value
-------- ---- ---- -----
6 DNS Servers IPv4Address {…, …, …}Three entries. Resolver order is a client-side affair anyway, but at least every client knows all three doors.
📦 The FritzBox gets demoted
Until now, my FritzBox did what every consumer router does: DHCP and DNS for the whole house. That doesn’t work with Active Directory. Domain members must use the domain controllers as their DNS servers, because only the DCs know the SRV records that tell a client where its domain lives. Handing out the router as DNS is the classic AD misconfiguration: the domain join fails, GPOs silently don’t apply, and logons take five minutes for no apparent reason.
So the DHCP server in the FritzBox is switched off, DHCP now runs as a role on the Windows servers (see below), and it hands out the three DCs as DNS servers, not the FritzBox.
The FritzBox isn’t unemployed, though. It stays the default gateway, so all internet traffic still leaves the house through it. And the DCs forward every DNS query they aren’t responsible for, meaning anything outside muench.home.arpa, to the FritzBox, which asks the outside world. A clean division of labour: Windows knows the house, the FritzBox knows the internet.
client ──DHCP────► HP1 / HP2 address, gateway = FritzBox, DNS = the 3 DCs
client ──DNS─────► any DC ──┬─ *.muench.home.arpa → answers itself
└─ everything else → forwards to FritzBox → internet
client ──traffic─► FritzBox (default gateway) ──────────────────────────► internetThe FritzBox got demoted from “runs the house” to “guards the front door”. It took it surprisingly well.
📡 DHCP failover: the pairwise surprise
My plan was simple: three servers, three DHCP nodes, all in one happy failover cluster.
Windows had other plans. DHCP failover is strictly pairwise. A failover relationship has exactly two partners, and a scope can only belong to one relationship. There’s no three-way mode, no quorum, no mesh. Windows DHCP is strictly monogamous. So the third DHCP role got uninstalled again, and HP1 and HP2 now share the scope in load-balance mode, splitting the address pool 50/50 and syncing leases between each other.
PS> Get-DhcpServerv4Failover | Select Name, Mode, State
Name Mode State
---- ---- -----
hp1-…-hp2 LoadBalance NormalThe pool itself is deliberately boring: one /24, and the dynamic range runs from .70 to .245. That’s 176 addresses for laptops, phones, TVs and whatever else walks through the door. Everything below .70 is statically configured and stays outside the pool: the router, the servers, their iLO interfaces and the other infrastructure that should never move. The top end stays free as spare room.
PS> Get-DhcpServerv4Scope | Select ScopeId, StartRange, EndRange, LeaseDuration
ScopeId StartRange EndRange LeaseDuration
------- ---------- -------- -------------
192.168.10.0 192.168.10.70 192.168.10.245 8.00:00:00
Option 3 Router
Option 6 DNS Servers (all three DCs)
Option 15 DNS Domain Name muench.home.arpaA few details worth knowing:
- 8-day leases. In a household where the same devices come back every day, long leases mean less chatter. They also mean a DHCP outage goes unnoticed for days instead of minutes.
- 50/50 load balancing. Both servers answer, and each one is responsible for half of the clients (decided by a hash of the MAC address). If one partner is gone, the other one keeps serving everyone, renewing its partner’s clients as well, in steps limited by the MCLT. After 60 minutes without contact it automatically switches to
PartnerDownand takes over the entire pool. (That automatic switch was off by default, and I only turned it on while writing this post.) - MCLT of one hour. The Maximum Client Lead Time limits how far a server may extend a lease beyond what its partner knows about. It’s the safety margin that stops two servers from handing out the same address after they lose contact.
- Dynamic DNS updates. Clients register themselves in the AD zones, so every laptop is reachable by name, and DHCP deletes the record when the lease expires.
- Authorization in AD. A Windows DHCP server only hands out leases once it’s authorized in Active Directory. A rogue DHCP server that some well-meaning person plugs in stays silent, as long as it’s a Windows one.
At the time of writing the pool is at a whopping 3% utilization. Enterprise-grade capacity planning.
Another small lesson: I wanted to keep a record of all the statically configured devices below the dynamic range, as “documentation reservations” directly in the DHCP console. Nope. Add-DhcpServerv4Reservation only accepts addresses inside the scope range. DHCP is not a CMDB, and it lets you know.
🔐 Secure Boot, and watching a DC wait for its friends
All three machines were installed UEFI/GPT with a TPM 2.0 on board, but Secure Boot was still off in firmware. So today was BIOS tour day: one server at a time, flip the switch, reboot, verify.
“One at a time” became a small lesson of its own. HP2 came back up while HP1 was still mid-reboot, and for about seven minutes HP2 answered ping, had every port open… and rejected every single login with HTTP 401. Lights on, nobody home.
Nothing was broken. A freshly booted domain controller wants to complete an initial sync with its replication partners before it fully takes over as a DC. One of those partners was still looking at a POST screen, so HP2 politely waited for a timeout. Once HP1 was back, everything turned green again: replication without errors, DHCP failover back from CommunicationInterrupted to Normal.
Lesson noted: don’t reboot your domain controllers in parallel. Which leads directly to the next topic.
🪦 WSUS: installed in my head, deprecated in reality
For any Windows admin of a certain age, “patch management” means WSUS. It was on my list: install it on HP2, sync the catalog, approve updates, feel like a real enterprise, and watch SUSDB grow like a sourdough starter nobody asked for.
Then I read the fine print. In 2024 Microsoft officially put WSUS on the deprecated list. It still ships with Server 2025 and keeps working, but it won’t get new features, and all the development effort goes to the cloud side: Windows Autopatch, Intune and Azure Update Manager. Learning a product in the year it’s marked for retirement felt like studying for an exam that’s already been cancelled. (Also, running WSUS with its database and IIS on a domain controller isn’t exactly best practice.)
So the updates come straight from Microsoft, and Group Policy steers when. Which brings us to the real star of this post.
📜 Group Policy: the YAML of the Windows world
If Ansible is “describe the desired state and let a tool enforce it”, Group Policy is the same idea, built into the operating system since 2000. It’s also delivered by the domain controllers themselves and re-applied every 90 minutes, with no playbook run required. Admittedly, I’m a little impressed. Please don’t tell my Ansible roles.
Here’s what’s live in the domain now, all of it created from PowerShell (New-GPO, Set-GPRegistryValue, New-GPLink) and then verified by reading the actual registry values back on the target machines:
1. Mapped network drives. HP3 has one folder and one SMB share per user (\\HP3\user1, \\HP3\user2, …). Each user owns their own folder with Full Control on both the NTFS and the share permissions, plus access-based enumeration, so you don’t even see the folders you can’t open. The user accounts live in their own OU, and a single GPO maps the drive letters. Group Policy Preferences with item-level targeting decide who gets what: user1 (that’s me, the admin of the house) gets every drive, and user2 only gets their own. The paths use the server name instead of the IP, so authentication goes via Kerberos instead of falling back to NTLM.
2. Windows Update for the clients. My desktop and my laptop rarely run overnight, so “install at 3 AM” is useless for them. Instead:
- quality updates deferred by 3 days, so the rest of the internet finds the bad patches first
- feature version pinned, so no surprise upgrade to the next Windows 11 release
- deadlines of 3 days (quality) and 7 days (feature), plus 2 days of grace, after which the reboot happens whether I like it or not. Windows Update has always been a “whether you like it or not” product; now at least I pick the date
- active hours 07:00–23:00
- “install updates and shut down” in the power menu, so shutting down in the evening does the work
- Delivery Optimization limited to the LAN, so updates only get downloaded once for the house
3. Windows Update for the servers: staggered. One base GPO on the Domain Controllers OU (auto-download, install on schedule), plus one small schedule GPO per server with security filtering, so each applies to exactly one machine:
HP3 Saturday 03:00 ← the canary
HP1 Sunday 03:00
HP2 Sunday 05:00HP3 is the canary in the patch mine: it gets updates a day before the others. If a cumulative update breaks something, I find out Saturday and still have a day to stop the rest. And HP1 and HP2 never reboot at the same time. See the previous section for why that matters.
Small side trap in the security filtering: when you remove “Authenticated Users” from the filter, the group still needs Read on the GPO, or nobody can read it anymore (MS16-072 sends its regards). The computer itself gets Apply, and Authenticated Users keeps Read.
4. Dark mode, and goodbye Spotlight. Purely cosmetic, and exactly my taste: every user in the house gets dark mode for Windows and apps, plus a plain black desktop. No Windows Spotlight, and no rotating “did you know this mountain is in Norway?” photos with a hotspot inviting you to learn more. Spotlight is basically a screensaver with a marketing department.
The catch here was the edition. The official policy “Turn off all Windows spotlight features” only works on Windows Enterprise and Education. My clients run Windows 11 Pro, where the policy is simply ignored. The workaround is Group Policy Preferences: instead of an official policy, the GPO writes the registry values that the Settings app itself would set:
HKCU\…\Themes\Personalize AppsUseLightTheme = 0 # dark apps
HKCU\…\Themes\Personalize SystemUsesLightTheme = 0 # dark taskbar/start
HKCU\…\Explorer\Wallpapers BackgroundType = 1 # solid color, kills Spotlight
HKCU\Control Panel\Colors Background = "0 0 0" # black
HKCU\Control Panel\Desktop Wallpaper = ""The action is set to Update, so a user can change the background, but the next policy refresh puts it back to black. On top of that, the GPO sets the official “Turn off Spotlight collection on Desktop” policy, as belt and braces for the day a machine gets upgraded to Enterprise. The changes kick in at the next logon.
5. BitLocker. That one deserves its own section.
🔒 BitLocker: what does it actually protect against?
Before switching anything on, I wanted a clear threat model, because “encryption = secure” is too simple.
BitLocker protects data at rest. Its job is the stolen laptop, the disk pulled out of a server, or the SSD that goes back under warranty. What it does not do: protect a running, unlocked system. Malware, a compromised account or a weak password see everything in plain text, just like before.
Then comes the question of how the key gets unlocked:
- TPM only: the TPM releases the key if the boot chain is unchanged. That’s convenient and protects the removed disk. But the whole machine boots on its own to the login screen, so if the whole machine gets stolen, the thief’s only obstacle is the Windows login.
- TPM + PIN: additionally a PIN before Windows even starts. The stolen device is now just a brick with an encrypted disk, or a very expensive paperweight with a fan. Less convenient, but for a laptop that travels with me, the right choice.
The funny part: I had already switched on BitLocker on my laptop the day before, TPM-only, and without the PIN. Good news: the PIN can be added later. It’s just an additional key protector, and nothing gets re-encrypted:
manage-bde -protectors -add C: -TPMAndPINThe catch: Windows only allows a pre-boot PIN if a policy permits it. So, surprise, another GPO. There are now two BitLocker policies in the domain:
- All clients: BitLocker is enabled automatically on the OS drive, and the recovery key gets backed up to Active Directory. No more keys sitting in a text file on some share, or on a sticky note under the keyboard. We’ve all seen it. Some of us have written it.
- The laptop: TPM + PIN is required at startup, via its own GPO with security filtering on exactly that machine.
With the GPOs in place, the next steps are gpupdate /force, adding the PIN protector, and then checking that the recovery key really shows up in the computer object in AD. A backup you’ve never checked is a hope, not a backup.
The servers come next, starting with HP3. As the future central file server, it gets the Storage Spaces volume encrypted too, and the keys go to AD. For a server that has to come back up unattended, TPM-only is the pragmatic choice, since nobody wants to type a PIN into iLO at 3 AM after a patch reboot.
💾 Storage Spaces: RAID 5, Microsoft style
HP3 got three 2 TB SATA SSDs for data (the OS lives on a separate SSD). On Linux, I’d have reached for mdadm or OpenZFS RAIDZ1. On Windows, the equivalent is Storage Spaces: a pool out of the physical disks, a virtual disk with parity resiliency on top (single parity with three disks, so RAID 5 in spirit), formatted with ReFS:
PS> New-StoragePool -FriendlyName BackupPool -PhysicalDisks $disks ...
PS> New-VirtualDisk -StoragePoolFriendlyName BackupPool -ResiliencySettingName Parity ...
PS> Format-Volume -FileSystem ReFS ...
D:\ ReFS ~3.6 TB usableThen of course it got benchmarked, with DiskSpd, Microsoft’s own storage load generator and roughly its answer to fio. Once again via the AI-over-WinRM route. The method:
- Get the tool: nothing gets installed. The server itself downloads the official
DiskSpd.zipfrom Microsoft’s GitHub releases intoC:\Temp, unpacks it, and uses theamd64binary. It’s a single portable.exe. - Test file: a 20 GB file directly on the parity volume
D:. - Four classic profiles: sequential with 1 MB blocks (one thread, queue depth 8) and random with 4 KB blocks (four threads, queue depth 32 each), each once for reading and once for writing.
- Fair conditions: 5 seconds warm-up, 30 seconds measurement,
-Shto bypass both the Windows file cache and the drives’ write cache (otherwise you’re benchmarking RAM),-Lfor latency percentiles. - Clean up: afterwards the tool and the test file get deleted again, and the only thing left on the server is a free volume.
# sequential write, 1 MB blocks
diskspd.exe -b1M -d30 -W5 -o8 -t1 -si -w100 -Sh -L D:\disktest.dat
# random read, 4 KB blocks, 4 threads x QD32
diskspd.exe -b4K -d30 -W5 -o32 -t4 -r -w0 -Sh -L D:\disktest.datA small helper script on the Linux side runs each profile over WinRM and only extracts the total: line and the 99th percentile from DiskSpd’s very chatty report. And of course the first benchmark lied to me, just like dd did on the ZFS box:
Sequential read (1 MB) 20997 MiB/s ← three SATA SSDs. Sure.
Random read (4 KB) 739327 IOPSTwenty gigabytes per second from three SATA drives with a combined interface limit of about 1.6 GB/s. The culprit: DiskSpd’s -c flag recreates the test file on every run, and ReFS knows that ranges which have never been written contain only zeros. So it answers those reads from metadata, without ever touching a disk. It’s a very fast benchmark of nothing at all. Schrödinger’s SSD: blazingly fast, as long as nobody looks at the data.
Fill the file once with real data, then measure without -c, and reality shows up:
Profile Throughput IOPS avg latency 99th pct
Sequential read (1 MB) 1169 MiB/s 1169 6.8 ms 24 ms
Random read (4 KB, 4x32) 633 MiB/s 162,109 0.55 ms 7.3 ms
Sequential write (1 MB) 88 MiB/s* 88 90.6 ms 427 ms
Random write (4 KB, 4x32) 1.8 MiB/s 461 276 ms 1095 ms
* up to 163 MiB/s in a separate 60-second runReads are great. Writes are… parity. Every small write turns into a read-modify-write of data plus parity, and without an SSD/NVMe write-back cache tier, Storage Spaces parity is notoriously slow at that. The latency column tells the same story: every hundredth small write waits more than a second. That’s enough time to reconsider your life choices. For a file server and backup target, where data gets written once and read often, that’s fine. For anything with lots of small random writes, it’s a no-go.
(For fairness: the run overlapped with a big copy job onto the same volume, so a clean re-measure is still on my list. But the pattern is clear.)
🧪 Next up: a hypervisor and my own certificates
“Anything with lots of small random writes” is, of course, a polite description of virtual machines. And that’s the next thing I want to play with: Hyper-V. Windows Server 2025 brings it along anyway, and I want to see how it feels compared to the Proxmox world I know: live migration between hosts, checkpoints, virtual switches, and nested virtualization for a test lab inside the lab.
The benchmark above already answered one design question before I even started. VM disks won’t live on the parity pool. With ~460 random-write IOPS, a Windows VM would spend its life staring at a spinning circle. The loading circle as a lifestyle. So a proper hypervisor setup needs a different storage layout: mirrored instead of parity, or local NVMe. That’s material for the next post.
The second item on the list: issuing my own certificates. Windows Server brings its own PKI, Active Directory Certificate Services. On the Linux side, cert-manager and Let’s Encrypt take care of everything that faces the internet. Internally, though, there’s a whole zoo that nobody will ever sign publicly: iLO web interfaces, RDP, LDAPS on the domain controllers, and the admin pages of assorted devices. Each of them currently greets me with a certificate warning that I’ve trained myself to click away. That’s exactly the reflex you don’t want to have.
The plan is a small internal CA, whose root certificate gets rolled out to every domain member via (of course) Group Policy. With certificate templates and auto-enrollment, machines request and renew their own certificates, like cert-manager, just the Microsoft edition. The textbook design is an offline root CA with an issuing CA underneath. Whether a homelab really needs two tiers, or whether that’s the moment where learning turns into cosplay, remains to be seen.
🎯 Takeaway
A week ago I would have told you Windows administration is clicking through wizards. It turns out that with PowerShell and WinRM it’s surprisingly scriptable, and Group Policy is basically configuration management with an operating system built around it.
The lessons along the way were the same as on Linux: read the fine print (DHCP failover is pairwise, WSUS is deprecated), don’t trust the first benchmark (ReFS and its zeros), and don’t reboot everything at once (domain controllers are sociable creatures).
Two tracks it is. Linux keeps running the production stuff, and Windows Server gets to be the playground where I learn. And honestly? After a week of YAML, Get-ADReplicationPartnerMetadata felt like a vacation. 🏖️
P.S. If you’ll excuse me now, I have to go explain to the family why their desktop wallpaper has suddenly turned black. “It’s a policy” has never once worked as an explanation at home.






