Build a local Active Directory lab on Hyper-V using Terraform. Scaffolds Terraform modules, unattended answer files, and PowerShell bootstrap scripts that create a domain controller and member servers on a Windows Hyper-V host. USE FOR: AD lab, Active Directory lab, Terraform Hyper-V, local domain lab, domain controller lab, Windows Server lab, WinRM Hyper-V, AD DS lab, lab environment, on-prem AD, local lab, terraform hyperv provider, unattended install lab, domain join automation, PowerShell Direct bootstrap. DO NOT USE FOR: Azure AD/Entra ID, cloud VMs, Azure Virtual Machines, AKS, cloud Terraform providers.
Scaffold and configure a Terraform project that deploys a local Active Directory lab on a Windows Hyper-V host. The lab consists of a domain controller VM and one or more member server VMs, all provisioned with unattended Windows installs and domain-joined via PowerShell Direct.
Hyper-V Host (Windows)
├── Terraform (taliesins/hyperv provider over WinRM)
│ ├── Module: hyperv → VMs, VHDs, virtual switches, DVD drives
│ └── Module: active-directory → Guest bootstrap via PowerShell Direct
├── Answer File ISOs → Unattended Windows Server install
└── PowerShell Bootstrap → AD forest promotion + domain join
Data flow: Terraform creates Gen2 VMs with OS disks and answer-file ISOs → VMs auto-install Windows → Terraform triggers a PowerShell script → script uses PowerShell Direct (VMBus, no network needed) to promote the DC and join members to the domain.
Before running this skill, the Hyper-V host must satisfy these requirements:
oscdimg or mkisofsAt minimum, one Internal virtual switch for domain traffic. Optionally an External switch for internet access.
The taliesins/hyperv provider connects to the local Hyper-V host over WinRM. The host must be configured correctly or Terraform will fail to authenticate. Apply every step below:
| # | Issue | Fix |
|---|---|---|
| 1 | WinRM service not running | winrm quickconfig -quiet and set the service to start automatically |
| 2 | Network profile is Public | Set-NetConnectionProfile -InterfaceAlias "vEthernet (*)" -NetworkCategory Private — Hyper-V virtual switches default to Public, which blocks WinRM |
| 3 | Unencrypted traffic blocked (HTTP mode) | Enable on both service and client: winrm set winrm/config/service '@{AllowUnencrypted="true"}' and winrm set winrm/config/client '@{AllowUnencrypted="true"}' |
| 4 | Basic authentication disabled | winrm set winrm/config/service/auth '@{Basic="true"}' |
| 5 | UAC remote token filtering blocks admin | Set registry: New-ItemProperty -Path HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System -Name LocalAccountTokenFilterPolicy -Value 1 -PropertyType DWord -Force |
| 6 | Microsoft-linked account fails | Use the built-in Administrator account — Microsoft-linked accounts fail Task Scheduler registration used by the provider |
| 7 | IP 127.0.0.1 causes Kerberos SPN mismatch | Use localhost as the host value instead of 127.0.0.1 |
| 8 | Operations timeout on large VHDs | Set hyperv_timeout to "300s" or more |
Create the following directory layout:
<project>/
├── main.tf
├── variables.tf
├── outputs.tf
├── providers.tf
├── versions.tf
├── terraform.tfvars
├── modules/
│ ├── hyperv/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ ├── locals.tf
│ │ └── versions.tf
│ └── active-directory/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── scripts/
├── Invoke-DomainBootstrap.ps1
├── autounattend-dc.xml
└── autounattend-node.xml
Use the taliesins/hyperv provider pinned to ~> 1.2.0. Connect over WinRM HTTP to localhost with NTLM authentication.
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
hyperv = {
source = "registry.terraform.io/taliesins/hyperv"
version = "~> 1.2.0"
}
}
}
# providers.tf
provider "hyperv" {
host = var.hyperv_host # "localhost"
port = var.hyperv_port # 5985 for HTTP
user = var.hyperv_user
password = var.hyperv_password
https = var.hyperv_https # false for HTTP
insecure = var.hyperv_insecure # true for HTTP
use_ntlm = var.hyperv_use_ntlm # true
script_path = var.hyperv_script_path
timeout = var.hyperv_timeout # "300s"
}
Critical: Never set hyperv_user or hyperv_password in terraform.tfvars. Blank values override environment variables due to Terraform variable precedence. Use TF_VAR_hyperv_user and TF_VAR_hyperv_password environment variables instead, or let Terraform prompt interactively.
The hyperv module creates VMs, OS disks, and attaches ISOs.
locals {
bytes_per_gib = 1073741824
dc_name = format("%s-DC01", var.vm_prefix)
vm_names = [for i in range(var.vm_count) : format("%s-SRV%02d", var.vm_prefix, i + 1)]
answer_iso_dc = "${var.vm_path}/AnswerISO/autounattend-dc.iso"
answer_iso_node = "${var.vm_path}/AnswerISO/autounattend-node.iso"
}
Create one hyperv_machine_instance for the DC with:
["IDE", "CD", "Floppy", "Network"]resource "hyperv_vhd" "dc_os_disk" {
path = "${var.vm_path}/${local.dc_name}/${local.dc_name}-OS.vhdx"
size = var.os_disk_size_gb * local.bytes_per_gib
}
resource "hyperv_machine_instance" "domain_controller" {
name = local.dc_name
path = var.vm_path
generation = 2
state = "Running"
processor_count = var.dc_processor_count
checkpoint_type = "Disabled"
dynamic_memory = true
memory_startup_bytes = var.dc_memory_startup_bytes
memory_minimum_bytes = var.dc_memory_minimum_bytes
memory_maximum_bytes = var.dc_memory_maximum_bytes
vm_firmware {
enable_secure_boot = "On"
secure_boot_template = "MicrosoftWindows"
boot_order {
boot_type = "HardDiskDrive"
controller_number = 0
controller_location = 0
}
boot_order {
boot_type = "DvdDrive"
controller_number = 0
controller_location = 1
}
boot_order {
boot_type = "NetworkAdapter"
network_adapter_name = "Management"
}
}
hard_disk_drives {
controller_type = "Scsi"
controller_number = 0
controller_location = 0
path = hyperv_vhd.dc_os_disk.path
}
dvd_drives {
controller_number = 0
controller_location = 1
path = var.iso_path # Windows Server ISO
}
dvd_drives {
controller_number = 0
controller_location = 2
path = local.answer_iso_dc
}
network_adaptors {
name = "Management"
switch_name = var.management_switch_name
}
network_adaptors {
name = "Internal"
switch_name = var.internal_switch_name
}
lifecycle {
ignore_changes = [dvd_drives]
}
}
Use for_each = toset(local.vm_names) to create member servers. Start them in state = "Off" so the bootstrap script can control power-on sequencing.
resource "hyperv_vhd" "node_os_disk" {
for_each = toset(local.vm_names)
path = "${var.vm_path}/${each.value}/${each.value}-OS.vhdx"
size = var.os_disk_size_gb * local.bytes_per_gib
}
resource "hyperv_machine_instance" "member_server" {
for_each = toset(local.vm_names)
name = each.value
path = var.vm_path
generation = 2
state = "Off"
processor_count = var.processor_count
checkpoint_type = "Disabled"
# ... same pattern: dynamic memory, firmware, disk, DVD drives, NICs
lifecycle {
ignore_changes = [dvd_drives, state]
}
depends_on = [hyperv_machine_instance.domain_controller]
}
Key lifecycle patterns:
ignore_changes = [dvd_drives] — Users eject ISOs after install; Terraform should not try to re-attach them.ignore_changes = [state] on member servers — The bootstrap script powers them on; Terraform should not revert them to Off.The active-directory module calls a PowerShell bootstrap script via terraform_data and local-exec.
resource "terraform_data" "ad_bootstrap" {
count = var.enable_guest_bootstrap ? 1 : 0
triggers_replace = [
var.domain_name,
var.domain_controller_name,
join(",", var.member_server_names),
sha256(var.guest_admin_password),
sha256(var.domain_safe_mode_password),
]
provisioner "local-exec" {
command = "pwsh -File '${var.bootstrap_script_path}' -DomainName '${var.domain_name}' -DomainControllerName '${var.domain_controller_name}' -MemberServerNames '${join(",", var.member_server_names)}' -GuestAdminUsername '${var.guest_admin_username}' -DomainControllerIPv4 '${var.domain_controller_ipv4}' -PrefixLength ${var.prefix_length}"
interpreter = ["pwsh", "-NoProfile", "-Command"]
environment = {
GUEST_ADMIN_PASSWORD = var.guest_admin_password
DOMAIN_SAFE_MODE_PASSWORD = var.domain_safe_mode_password
}
}
}
Security pattern: Passwords are passed as environment variables, never on the command line. Trigger hashes use sha256() to detect changes without storing secrets in Terraform state.
Invoke-DomainBootstrap.ps1 uses PowerShell Direct (Invoke-Command -VMName) to configure VMs over the VMBus — no network connectivity required.
1. Start member VMs (they were created as "Off")
2. Wait for all VMs to finish Windows install (parallel background jobs)
3. Configure DC: static IP → DNS to 127.0.0.1 → Install AD DS → Install-ADDSForest
4. Wait for AD services to come online
5. Configure members: static IP → DNS to DC → Rename-Computer → Reboot
6. Domain join members: Add-Computer -DomainName → Reboot
7. Verify all members report domain membership
PowerShell Direct for guest access:
$cred = New-Object PSCredential($GuestAdminUsername, (ConvertTo-SecureString $env:GUEST_ADMIN_PASSWORD -AsPlainText -Force))
Invoke-Command -VMName $VMName -Credential $cred -ScriptBlock { ... }
Wait for OS install with retries:
$maxRetries = 45
$delay = 20 # seconds
for ($i = 0; $i -lt $maxRetries; $i++) {
try {
Invoke-Command -VMName $VMName -Credential $cred -ScriptBlock { $env:COMPUTERNAME }
break
} catch {
Start-Sleep -Seconds $delay
}
}
Idempotent DC promotion:
$cs = Get-CimInstance Win32_ComputerSystem
if ($cs.PartOfDomain -and $cs.Domain -ieq $DomainName) {
Write-Host "Already a DC in $DomainName — skipping"
return
}
Install-WindowsFeature AD-Domain-Services -IncludeManagementTools
Install-ADDSForest -DomainName $DomainName -SafeModeAdministratorPassword $dsrmPwd -Force -NoRebootOnCompletion:$false
Handle reboot disconnections: AD promotion and domain join both reboot the VM, which drops the PowerShell Direct session. Catch and tolerate these known errors:
catch {
if ($_.Exception.Message -match 'broken pipe|transport connection|socket.*ended') {
Write-Host "Expected reboot disconnection — continuing"
} else {
throw
}
}
Idempotent domain join:
$cs = Get-CimInstance Win32_ComputerSystem
if ($cs.PartOfDomain -and $cs.Domain -ieq $DomainName) {
Write-Host "$VMName already joined to $DomainName — skipping"
return
}
Add-Computer -DomainName $DomainName -Credential $domainCred -Force -Restart
Adapter selection heuristic: Select the internal adapter (no default gateway) for static IP assignment:
$adapter = Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } |
Where-Object { -not (Get-NetIPConfiguration -InterfaceIndex $_.ifIndex).IPv4DefaultGateway }
Create two answer files (one for DC, one for member servers) and burn them to ISO.
Key differences:
| Setting | DC Answer File | Member Answer File |
|---|---|---|
ComputerName | Fixed name (e.g., LAB-DC01) | * (auto-generated, renamed later by bootstrap) |
Common settings for both:
<!-- Disk configuration for UEFI Gen2 VMs -->
<DiskConfiguration>
<Disk wcm:action="add">
<DiskID>0</DiskID>
<WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add"><Order>1</Order><Type>EFI</Type><Size>260</Size></CreatePartition>
<CreatePartition wcm:action="add"><Order>2</Order><Type>MSR</Type><Size>16</Size></CreatePartition>
<CreatePartition wcm:action="add"><Order>3</Order><Type>Primary</Type><Size>1024</Size></CreatePartition>
<CreatePartition wcm:action="add"><Order>4</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Format>FAT32</Format><Label>System</Label></ModifyPartition>
<ModifyPartition wcm:action="add"><Order>2</Order><PartitionID>2</PartitionID></ModifyPartition>
<ModifyPartition wcm:action="add"><Order>3</Order><PartitionID>3</PartitionID><Format>NTFS</Format><Label>WinRE</Label><TypeID>de94bba4-06d1-4d40-a16a-bfd50179d6ac</TypeID></ModifyPartition>
<ModifyPartition wcm:action="add"><Order>4</Order><PartitionID>4</PartitionID><Format>NTFS</Format><Label>Windows</Label><Letter>C</Letter></ModifyPartition>
</ModifyPartitions>
</Disk>
</DiskConfiguration>
Burn answer files to ISO (from an elevated prompt):
oscdimg -n -o <answer-file-folder> <output-iso-path>
Root variables.tf must include:
| Variable | Purpose | Notes |
|---|---|---|
hyperv_host | WinRM target | Default "localhost" (not 127.0.0.1) |
hyperv_port | WinRM port | 5985 for HTTP, 5986 for HTTPS |
hyperv_user | Admin username | sensitive = true, non-empty validation |
hyperv_password | Admin password | sensitive = true, non-empty validation |
vm_prefix | Name prefix | e.g., "LAB" → LAB-DC01, LAB-SRV01 |
vm_count | Number of member servers | >= 1 |
vm_path | VM storage root | e.g., "D:\\Hyper-V\\ADLab" |
iso_path | Windows Server ISO | Full path on host |
domain_name | AD domain FQDN | e.g., "lab.local" |
guest_admin_username | Local admin on guest VMs | Usually "Administrator" |
guest_admin_password | Local admin password | sensitive = true |
domain_safe_mode_password | DSRM password | sensitive = true |
domain_controller_ipv4 | Static IP for DC | On internal network |
member_server_ipv4s | Static IPs for members | List, one per VM |
enable_guest_bootstrap | Toggle AD bootstrap | true/false |
Credential validation pattern:
variable "hyperv_user" {
type = string
sensitive = true
validation {
condition = length(trimspace(var.hyperv_user)) > 0
error_message = "hyperv_user must not be empty."
}
}
# Set credentials via environment variables
$env:TF_VAR_hyperv_user = 'HOSTNAME\Administrator'
$env:TF_VAR_hyperv_password = 'YourPassword'
# Initialize and apply
terraform init
terraform apply
After apply completes, verify domain membership:
Invoke-Command -VMName LAB-SRV01 -Credential $domainCred -ScriptBlock {
(Get-CimInstance Win32_ComputerSystem).Domain
}
# Expected output: lab.local
| Symptom | Cause | Fix |
|---|---|---|
Anonymous authentication not supported | hyperv_user/hyperv_password blank in tfvars | Remove credentials from tfvars; use TF_VAR_* env vars |
WinRM cannot process the request | WinRM not configured on host | Run the full WinRM checklist in Prerequisites |
The network path was not found | Using 127.0.0.1 instead of localhost | Set hyperv_host = "localhost" |
| Terraform timeout creating VHDs | Default 30s timeout too short | Set hyperv_timeout = "300s" |
| Bootstrap hangs waiting for VM | Windows install didn't start | Verify ISO paths and answer file ISO is valid |
broken pipe during AD promotion | Expected — DC reboots after forest install | Already handled in bootstrap script error catching |
| VMs re-created on every apply | dvd_drives or state not in ignore_changes | Add lifecycle { ignore_changes = [dvd_drives, state] } |
vm_count for more or fewer member serversInvoke-DomainBootstrap.ps1 to install DHCP, DNS zones, Certificate Services, etc.