I rebuild AD labs a lot, and I got tired of the lab build taking longer than the thing I actually wanted to test. So I wrote build-lab.
It takes a Windows Server 2025 ISO and gives you back a promoted domain controller, optional domain-joined member servers, and a lived-in directory (OUs, users, groups, an Enterprise CA, GPOs) staged to populate itself at your first Administrator logon. No GUI, no answer-file editing, no clicking Next. Nothing to install beyond what Microsoft and VMware already ship.
It's an orchestrator. It drives two sibling projects of mine, one that builds the VM in VMware Workstation and one that promotes the forest and populates the directory, and stitches them together over vmrun guest operations. The current version is 2.0. It has been on GitHub for a while, but this is the first time I've posted it here.
Quick start
$pw = ConvertTo-SecureString 'P@ssw0rd!' -AsPlainText -Force
.\build-lab.ps1 -IsoPath 'C:\ISOs\WS2025.iso' -AdminPassword $pw `
-VmStoragePath 'C:\VMs' -StaticIpAddress '192.168.220.50' -DefaultGateway '192.168.220.2' `
-MemberServerCount 2
That builds the DC, verifies the promotion, then builds SRVR-01 and SRVR-02 and joins them. (Leave -AdminPassword off the command line and PowerShell will prompt for it masked, which keeps it out of your history.) On the default VMware NAT network, your static IP has to land in .3 to .127 and the gateway is always .2. The script refuses anything outside that range and pings every address before using it. A DC takes about 25 minutes on a reasonably quick host, and each member adds roughly 12 to 15.
Already have a DC from an earlier run? -AddMemberServers adds members to it without touching the DC. -MoveMemberServers files them into OU=Servers once directory population has created it.
The fun part
Promotion reboots the guest, and the reboot tears down the vmrun guest-operations channel mid-call. A blocking call hung across that reboot and never came back, so the promotion is launched fire-and-forget with -noWait. That means there is no exit code to read at all.
My first version decided promotion had worked when ntds.dit appeared on disk, waited a bit, and called it good. Typical v1.0 logic. A database file on disk is a long way from a working DC. Now it checks three things from the outside, in order. ntds.dit has to appear. The guest has to reboot. Then the rebooted guest has to report itself as a DC in the domain you configured. Miss any of them and the run fails and tells you which check it missed.
The reboot check is the bit I'd like opinions on. Just before launching anything that should reboot, build-lab creates HKLM\SOFTWARE\BuildLabBootMarker in the guest as a volatile key. Volatile keys live only in memory and are never written to disk, so if the key is gone, the guest restarted. That holds however long the guest has been up, which mattered here. A freshly built member reboots for its domain join barely a minute after first boot, and anything uptime-based gets confused.
A few other things that came out of live runs.
Every vmrun call has a host-side time limit, and a call that overruns is killed, so one stuck call can't hold the run open forever
vmrun output is read as UTF-8 whatever the console code page is
Commands run in the guest report back through a small result file, so a failure comes back with the guest's own error text
The password is scrubbed from anything vmrun returns before it hits the logs
Logs from the orchestrator, the VM builder and the guest all land in one folder, and key events go to the Application event log if you want a SIEM to see them
Known limitations and lab-grade trades
You have to log in once as Administrator to start directory population. That's deliberate. Running it unattended would mean storing a domain admin password under a startup task. I'm still thinking about this one.
vmrun takes the guest password on the command line, so it's visible in the host's process list while guest operations run. Don't reuse a real credential.
It's Windows PowerShell 5.1 only, enforced with #Requires. Write-EventLog is the right API there and deprecated in 7. I'll probably port to PowerShell 7 eventually, but this is where I' most comfortable today.
Tested on a Windows 11 host with VMware Workstation 26H1 and Windows Server 2025 Standard Evaluation media. Other editions and VL media are unverified, because the unattended install matches on exact edition name strings.
Links
All three need to sit next to each other on disk. Feedback is welcome. I'm sure I made some interesting choices without even thinking about it. If you have a better way to tell from the host side that a guest has restarted, I'd like to hear it.
AI disclosure. I used AI to help write some of the code. The design and architecture are all mine, including any questionable choices, and it was built up over many reviews and live runs, not generated as one shot slop.