Infrastructure-as-code for a full lab Active Directory environment:
Terraform provisions 5 VMs on vSphere, Ansible then builds out a
real AD forest (labhandzone.local), a 2-node SQL Server Failover Cluster
Instance on iSCSI shared storage, a file server, a WSUS server, and a
GPO-driven ring-based patching process the same infrastructure shape a
lot of real mid-size environments still run today.
Companion to windows-fleet-monitoring (which monitors a fleet like this one) and fleet-monitoring-stack / linux-audit-toolkit on the Linux side.
- Architecture
- What gets built
- Prerequisites
- Deployment sequence
- Secrets
- The patching process (what you actually asked for)
- Continuous integration
- Known limitations — read this before a real run
- Roadmap
- License
flowchart TB
TF["Terraform (vSphere)"]
subgraph VMs["5 VMs, provisioned in WORKGROUP mode"]
DC["dc01"]
SQL1["sql01"]
SQL2["sql02"]
FS["fs01"]
WSUS["wsus01"]
end
TF -->|"apply"| VMs
subgraph Domain["labhandzone.local"]
DC
end
SQL1 -.->|"domain join"| Domain
SQL2 -.->|"domain join"| Domain
FS -.->|"domain join"| Domain
WSUS -.->|"domain join"| Domain
FS -->|"iSCSI: quorum LUN + data LUN"| Cluster
FS -->|"file share witness"| Cluster
subgraph Cluster["WSFC: sqlcluster01"]
SQL1
SQL2
end
Cluster -->|"SQL Server FCI: SQLFCI01"| SQLFCI["SQL Server (Failover Cluster Instance)"]
WSUS -->|"patch content"| SQL1
WSUS -->|"patch content"| SQL2
WSUS -->|"patch content"| FS
WSUS -->|"patch content"| DC
DC -->|"GPOs: Pilot + Broad rings"| WSUS
| VM | Role | Notable detail |
|---|---|---|
dc01 |
First DC of a new forest, labhandzone.local |
Also DNS for the whole lab |
sql01 |
SQL Server FCI node 1 (cluster creator) | 4 vCPU / 8GB RAM |
sql02 |
SQL Server FCI node 2 | 4 vCPU / 8GB RAM |
fs01 |
File server + iSCSI Target Server | 2 extra disks (5GB quorum, 100GB data) become iSCSI LUNs |
wsus01 |
WSUS server | 150GB disk for patch content |
labhandzone-infra/
├── README.md
├── LICENSE
├── terraform/vsphere/ # 5 VMs — see terraform/vsphere/README.md
│ ├── main.tf, variables.tf, outputs.tf, versions.tf
│ ├── terraform.tfvars.example
│ └── modules/windows_vm/ # reusable VM-from-template module
└── ansible/
├── ansible.cfg, playbook.yml, requirements.yml
├── inventory/hosts.ini, group_vars/*.yml
└── roles/
├── ad_domain_controller/ # Phase 1
├── domain_join/ # Phase 2
├── iscsi_target/ # Phase 3 (fs01)
├── file_server/ # Phase 3 (fs01) — witness + data shares
├── iscsi_initiator/ # Phase 4 (sql01/sql02)
├── windows_failover_cluster/ # Phase 5
├── sql_server_fci/ # Phase 6
├── wsus_server/ # Phase 7
└── gpo_patching/ # Phase 8
- A vSphere environment with an existing Windows Server template (VMware Tools installed) Terraform clones from it, it doesn't build it. See terraform/vsphere/README.md.
- Ansible control node:
ansible-core2.15+,pywinrm, and theansible.windows/community.windows/microsoft.adcollections (ansible/requirements.yml) - SQL Server installation media licensed software this repo cannot
provide. Mount it at
sql_server_media_pathon both SQL nodes before Phase 6. - Every VM needs
windows-fleet-monitoring's WinRM bootstrap applied (or equivalent) before Ansible can reach it — Terraform's guest customization sets networking/hostname but does not enable WinRM. See that repo'sbootstrap/for the GPO startup-script pattern; the same approach applies here.
# 1. Provision the VMs
cd terraform/vsphere
cp terraform.tfvars.example terraform.tfvars # fill in real values
terraform init && terraform apply
# 2. Enable WinRM on each VM (GPO startup script, or manually for a lab —
# see windows-fleet-monitoring/bootstrap/)
# 3. Build out the domain, storage, cluster, SQL, WSUS, and GPOs
cd ../../ansible
pip install pywinrm
ansible-galaxy collection install -r requirements.yml
cp inventory/hosts.ini inventory/hosts.ini.local # fill in real hostnames/IPs
# Mount SQL Server media on sql01 and sql02 at D:\SQLServerSetup before
# this next command reaches Phase 6.
ansible-playbook -i inventory/hosts.ini.local playbook.yml --check --diff # dry run
ansible-playbook -i inventory/hosts.ini.local playbook.ymlThe playbook is one file with 8 ordered plays (Phase 1 through 8 — see
ansible/playbook.yml). Don't --limit to a later phase on a completely
fresh environment; each phase depends on the state the previous one left
behind (the domain has to exist before anything can join it, storage has
to be attached before the cluster can claim it, etc.).
Every CHANGE_ME_VIA_ANSIBLE_VAULT placeholder across group_vars/*.yml
needs a real value before a real run:
ansible-vault encrypt_string 'RealPassword123!' --name 'vault_local_admin_password'That covers: the shared local Administrator password (set by Terraform's
guest customization, reused by Ansible throughout — see the comment in
inventory/hosts.ini on why), the AD Safe Mode password, and the SQL
Server service account passwords. Never commit real values everything
sensitive in this repo is a placeholder by design.
gpo_patching implements a two-ring model, the same shape most real
patch-management processes use:
- Pilot ring a small set of machines (put your less-critical or
canary servers' computer objects in
OU=Pilot,OU=PatchRings,...) installs approved updates within 1 day, with WSUS detection checking in every 6 hours. This is where you find out an update breaks something, before it reaches anything that matters. - Broad ring — everything else, in
OU=Broad,OU=PatchRings,..., installs the same updates a week later, once Pilot has proven them safe.
Both rings get their own GPO with WSUS client-targeting registry values
(WUServer, TargetGroup, ScheduledInstallDay/Time) move a computer
object into the right OU, and Group Policy handles the rest on its next
refresh. Approvals themselves still happen in the WSUS console (or via the
WSUS PowerShell module) GPOs control when a machine checks for and
installs approved updates, not which updates get approved; that
separation is deliberate and matches how WSUS actually works.
Every push/PR runs via GitHub Actions (.github/workflows/ci.yml):
yamllintacrossansible/ansible-playbook --syntax-check+ansible-lint(non-blocking — samevar-naming[no-role-prefix]style findings as the sibling repos, not hidden, just not yet fixed)terraform fmt -check+terraform validatethis was written without a localterraformbinary (sandboxed dev environment, no route to HashiCorp's release servers), so this CI job is the actual first verification these.tffiles get- PowerShell template syntax check the two
.ps1.j2templates (gpo_patching,wsus_server) are rendered with real Jinja2 and representative dummy data, then parsed by the genuine PowerShell parser on awindows-latestrunner. This replaced an earlier, weaker approach (regex-stripping Jinja tags) after testing showed it produced ambiguous, falsely-failing PowerShell for the loop-based template endering with the real templating engine first is the correct fix, not a workaround.
Being direct about what has and hasn't actually been verified, same as the sibling repos:
- Nothing here has run against a real vCenter or real Windows Server
VMs end-to-end. Every piece is syntax/schema/structurally validated
(see Continuous integration above) and hand-reviewed carefully — several
real bugs were caught and fixed this way during development (an invalid
vsphere_virtual_machine.tagsusage, a broken diskunit_numbercalculation, a wrong assumption iniscsi_initiatorabout disks arriving pre-formatted, a YAML-breaking typo insql_server_fci) but syntax-valid is not the same as "this cluster will actually form." Budget real troubleshooting time for the first live run, especially around WSFC quorum/networking and SQL FCI setup. - SQL Server FCI setup is the least-verified piece. The
ConfigurationFile.iniparameter set is accurate as of SQL Server 2019/2022 from Microsoft's documented reference, but exact requirements shift slightly by version/edition/patch level — cross-check against Microsoft's current docs before a real run, and checkC:\Program Files\Microsoft SQL Server\<version>\Setup Bootstrap\Log\on the target ifsetup.exefails; Ansible's own error output won't explain a SQL setup failure in useful detail. - Single DC, single forest, lab-appropriate simplifications throughout
— no second DC for redundancy, the witness share is permissioned to
Domain Computersrather than the cluster's specific computer account (documented reasoning infile_server's tasks), WSUS uses the Windows Internal Database rather than a real SQL back end. All reasonable for a lab; all worth hardening before treating this as a production pattern. - Terraform's guest customization sets networking, not WinRM. You
still need the bootstrap step from
windows-fleet-monitoring(or equivalent) before Ansible can reach any of these VMs.
- Actually run this against a real vSphere lab end-to-end and fix whatever the first real run inevitably surfaces
- Second DC for redundancy, rather than a single point of failure
- Harden the witness share permission to the cluster's specific
computer account instead of
Domain Computers, as a post-cluster- creation follow-up task - Point WSUS at a real SQL back end instead of WID
- Packer template to build the Windows Server VM template Terraform clones from, so the whole chain — template build through GPO patching — is code, not a manual first step
- Always On Availability Group as an alternative to FCI in a separate
ansible/roles/sql_server_ag/, for environments without shared storage
MIT — see LICENSE.