Skip to content

P03-T01 - HQ-DC01 Build and Promotion

Objective

Clone the Windows Server template, deploy HQ-DC01 into VLAN 20, and build the first domain controller with all required core components for the lab and future enterprise estate:

  • AD DS
  • DNS
  • DHCP
  • future-proof UPN suffix strategy
  • initial enterprise OU hierarchy
  • baseline reverse zones and DHCP scope

This runbook assumes the strategic naming standard already approved in this guide:

  • AD domain: corp.gntech.me
  • user sign-in suffix: gntech.me
  • enterprise management root OU: OU=GNTECH
  • object-first OU model for standard identities and devices
  • tier OUs reserved for privileged administration structure

Inputs

Key Value
Source template 4002 / TPL-WS2025-CORE
Recommended VMID 4011
Target VM HQ-DC01
Target VLAN 20
Static IP 172.20.20.11/24
Gateway 172.20.20.1
Forest/domain FQDN corp.gntech.me
NetBIOS name GNTECH
User UPN suffix gntech.me
DHCP scope 172.20.30.100-172.20.30.199

Target State

Property Value
Role AD DS, DNS, DHCP
Hostname HQ-DC01
Network Static on VLAN 20
Domain First DC in corp.gntech.me
Forest First forest in corp.gntech.me
DNS forwarders 1.1.1.1, 9.9.9.9
Reverse zones 172.20.20.0/24, 172.20.30.0/24
DHCP authorization Authorized in AD
Enterprise root OU OU=GNTECH

Prechecks

  • Complete P02-T05.
  • Confirm VMID 4002 is still a clean reusable template.
  • Reserve VMID 4011 and hostname HQ-DC01 in the client overlay.
  • Confirm route and DNS reachability from VLAN 20 to WAN if updates are required.
  • Snapshot the new clone before role installation if your storage policy allows it.
  • Be ready to sign in directly to HQ-DC01 after cloning.
  • Be ready to enter both a strong local Administrator password and the DSRM password securely on the server itself. Do not hardcode or commit either value into any script.

Execution

Validated against the live lab on H1 on Thursday, July 16, 2026:

  • HQ-DC01 was deployed successfully from 4002 (Windows Server 2025 Core).
  • The initial clone inherited stale network state from the template, including an extra default route.
  • The stage 1 script in this repository now corrects the exact IP, DNS, and default route state instead of assuming a clean adapter configuration.
  • Proxmox is used only to clone, size, network, snapshot, and power the VM.
  • All Windows role configuration is executed by the sysadmin directly in the guest OS, not through Proxmox guest agent automation.

  • Clone the template on H1 and resize the system disk:

qm clone 4002 4011 \
  --name HQ-DC01 \
  --full 1 \
  --storage local-zfs

qm set 4011 \
  --cores 2 \
  --memory 4096 \
  --balloon 0 \
  --net0 virtio,bridge=GEILLAN,tag=20,firewall=0

qm resize 4011 scsi0 80G

qm start 4011
  1. Bootstrap the guest network through QGA so the sysadmin can connect by RDP.

This block exists because the sysadmin needs a predictable management path before any in-guest deployment script is run.

Validated lab model as of Saturday, July 18, 2026:

  • Proxmox clones and powers the VM
  • QGA sets the first static network state
  • RDP is enabled
  • the sysadmin then signs in and runs the Windows deployment script directly inside the server

Run this direct command from H1. Change the values before running it if the deployment differs:

VMID=4011
IPV4_ADDRESS='172.20.20.11'
PREFIX_LENGTH='24'
GATEWAY='172.20.20.1'
DNS1='172.20.20.1'
DNS2='1.1.1.1'

qm guest exec "$VMID" -- powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "
\$adapter = Get-NetAdapter | Where-Object Status -eq 'Up' | Sort-Object ifIndex | Select-Object -First 1;
if (-not \$adapter) { throw 'No active network adapter was found.' }

Get-NetIPAddress -InterfaceIndex \$adapter.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue |
  Where-Object { \$_.IPAddress -notlike '169.254.*' } |
  ForEach-Object {
    Remove-NetIPAddress -InterfaceIndex \$adapter.ifIndex -AddressFamily IPv4 -IPAddress \$_.IPAddress -Confirm:\$false -ErrorAction SilentlyContinue
  };

Get-NetRoute -InterfaceIndex \$adapter.ifIndex -DestinationPrefix '0.0.0.0/0' -AddressFamily IPv4 -ErrorAction SilentlyContinue |
  ForEach-Object {
    Remove-NetRoute -InterfaceIndex \$adapter.ifIndex -DestinationPrefix '0.0.0.0/0' -NextHop \$_.NextHop -Confirm:\$false -ErrorAction SilentlyContinue
  };

New-NetIPAddress -InterfaceIndex \$adapter.ifIndex -IPAddress '$IPV4_ADDRESS' -PrefixLength $PREFIX_LENGTH -DefaultGateway '$GATEWAY' | Out-Null;
Set-DnsClientServerAddress -InterfaceIndex \$adapter.ifIndex -ServerAddresses '$DNS1','$DNS2';

Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name 'fDenyTSConnections' -Value 0;
Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' | Out-Null;

Write-Host 'Bootstrap complete';
Write-Host ('Adapter: ' + \$adapter.Name);
Write-Host ('IP: $IPV4_ADDRESS/$PREFIX_LENGTH');
Write-Host ('Gateway: $GATEWAY');
Write-Host ('DNS: $DNS1, $DNS2');
"

Values the sysadmin may change in that command:

Variable What the admin can change
VMID target clone VMID
IPV4_ADDRESS static server IP
PREFIX_LENGTH IPv4 prefix length
GATEWAY default gateway for the server VLAN
DNS1 primary DNS server used before promotion
DNS2 secondary DNS server used before promotion

Expected result:

  • the server answers on 172.20.20.11
  • RDP is enabled
  • the Windows firewall Remote Desktop rule group is enabled

  • Sign in directly to HQ-DC01 by RDP as the local built-in Administrator.

  • Copy the stage 1 script to the server and run it locally in PowerShell:

.\Deploy-HQ-DC01-Stage1.ps1
Show full DC01 Stage 1 script
param(
    [string]$TargetName,
    [string]$TimeZone,
    [string]$IPv4,
    [Nullable[int]]$PrefixLength,
    [string]$Gateway,
    [string[]]$DnsServers,
    [switch]$RestartIfNeeded
)

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

function Read-RequiredValue {
    param(
        [string]$Prompt,
        [string]$DefaultValue
    )

    $fullPrompt = $Prompt
    if ($DefaultValue) {
        $fullPrompt = "$Prompt [$DefaultValue]"
    }

    while ($true) {
        $value = Read-Host -Prompt $fullPrompt
        if (-not [string]::IsNullOrWhiteSpace($value)) {
            return $value.Trim()
        }

        if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) {
            return $DefaultValue
        }
    }
}

function Read-RequiredInt {
    param(
        [string]$Prompt,
        [int]$DefaultValue
    )

    while ($true) {
        $value = Read-RequiredValue -Prompt $Prompt -DefaultValue $DefaultValue
        $parsed = 0
        if ([int]::TryParse($value, [ref]$parsed)) {
            return $parsed
        }
    }
}

function Read-DnsServerList {
    param(
        [string[]]$DefaultServers
    )

    while ($true) {
        $defaultValue = $DefaultServers -join ','
        $value = Read-RequiredValue -Prompt 'DNS servers (comma-separated)' -DefaultValue $defaultValue
        $servers = @(
            $value.Split(',') |
            ForEach-Object { $_.Trim() } |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
        )

        if ($servers.Count -gt 0) {
            return $servers
        }
    }
}

function Resolve-DeploymentInputs {
    $script:TargetName = if ([string]::IsNullOrWhiteSpace($TargetName)) {
        Read-RequiredValue -Prompt 'Target hostname' -DefaultValue 'HQ-DC01'
    } else {
        $TargetName.Trim()
    }

    $script:TimeZone = if ([string]::IsNullOrWhiteSpace($TimeZone)) {
        Read-RequiredValue -Prompt 'Windows time zone ID' -DefaultValue 'SA Western Standard Time'
    } else {
        $TimeZone.Trim()
    }

    $script:IPv4 = if ([string]::IsNullOrWhiteSpace($IPv4)) {
        Read-RequiredValue -Prompt 'IPv4 address' -DefaultValue '172.20.20.11'
    } else {
        $IPv4.Trim()
    }

    $script:PrefixLength = if ($null -eq $PrefixLength) {
        Read-RequiredInt -Prompt 'IPv4 prefix length' -DefaultValue 24
    } else {
        [int]$PrefixLength
    }

    $script:Gateway = if ([string]::IsNullOrWhiteSpace($Gateway)) {
        Read-RequiredValue -Prompt 'Default gateway' -DefaultValue '172.20.20.1'
    } else {
        $Gateway.Trim()
    }

    $script:DnsServers = if ($DnsServers.Count -eq 0) {
        Read-DnsServerList -DefaultServers @('127.0.0.1', '1.1.1.1')
    } else {
        @(
            $DnsServers |
            ForEach-Object { $_.Trim() } |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
        )
    }
}

function Get-PrimaryAdapter {
    $adapter = Get-NetAdapter |
        Where-Object Status -eq 'Up' |
        Sort-Object ifIndex |
        Select-Object -First 1

    if (-not $adapter) {
        throw 'No active network adapter was found.'
    }

    return $adapter
}

function Set-ExactDnsServers {
    param(
        [int]$InterfaceIndex,
        [string[]]$DesiredServers
    )

    $current = (Get-DnsClientServerAddress -InterfaceIndex $InterfaceIndex -AddressFamily IPv4).ServerAddresses
    if (@($current) -join ',' -ne @($DesiredServers) -join ',') {
        Set-DnsClientServerAddress -InterfaceIndex $InterfaceIndex -ServerAddresses $DesiredServers
    }
}

function Set-ExactIPv4Configuration {
    param(
        [int]$InterfaceIndex,
        [string]$DesiredIPv4,
        [int]$DesiredPrefixLength,
        [string]$DesiredGateway
    )

    $existing = Get-NetIPAddress -InterfaceIndex $InterfaceIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue

    foreach ($address in $existing) {
        if ($address.IPAddress -eq $DesiredIPv4 -and $address.PrefixLength -eq $DesiredPrefixLength) {
            continue
        }

        if ($address.IPAddress -like '169.254.*') {
            continue
        }

        Remove-NetIPAddress `
            -InterfaceIndex $InterfaceIndex `
            -AddressFamily IPv4 `
            -IPAddress $address.IPAddress `
            -Confirm:$false `
            -ErrorAction SilentlyContinue
    }

    $desiredAddress = Get-NetIPAddress `
        -InterfaceIndex $InterfaceIndex `
        -AddressFamily IPv4 `
        -ErrorAction SilentlyContinue |
        Where-Object {
            $_.IPAddress -eq $DesiredIPv4 -and
            $_.PrefixLength -eq $DesiredPrefixLength
        } |
        Select-Object -First 1

    if (-not $desiredAddress) {
        New-NetIPAddress `
            -InterfaceIndex $InterfaceIndex `
            -IPAddress $DesiredIPv4 `
            -PrefixLength $DesiredPrefixLength
    }

    $defaultRoutes = Get-NetRoute `
        -InterfaceIndex $InterfaceIndex `
        -DestinationPrefix '0.0.0.0/0' `
        -AddressFamily IPv4 `
        -ErrorAction SilentlyContinue

    foreach ($route in $defaultRoutes) {
        if ($route.NextHop -ne $DesiredGateway) {
            Remove-NetRoute `
                -InterfaceIndex $InterfaceIndex `
                -DestinationPrefix '0.0.0.0/0' `
                -NextHop $route.NextHop `
                -Confirm:$false `
                -ErrorAction SilentlyContinue
        }
    }

    $desiredRoute = Get-NetRoute `
        -InterfaceIndex $InterfaceIndex `
        -DestinationPrefix '0.0.0.0/0' `
        -AddressFamily IPv4 `
        -ErrorAction SilentlyContinue |
        Where-Object NextHop -eq $DesiredGateway |
        Select-Object -First 1

    if (-not $desiredRoute) {
        New-NetRoute `
            -InterfaceIndex $InterfaceIndex `
            -DestinationPrefix '0.0.0.0/0' `
            -NextHop $DesiredGateway `
            -RouteMetric 256 | Out-Null
    }
}

function Install-RequiredFeatures {
    $requiredFeatures = 'AD-Domain-Services', 'DNS', 'DHCP'
    $missingFeatures = Get-WindowsFeature $requiredFeatures |
        Where-Object InstallState -ne 'Installed' |
        Select-Object -ExpandProperty Name

    if ($missingFeatures) {
        Install-WindowsFeature -Name $missingFeatures -IncludeManagementTools | Out-Null
    }
}

Resolve-DeploymentInputs

$adapter = Get-PrimaryAdapter
$restartRequired = $false

if ((Get-TimeZone).Id -ne $TimeZone) {
    Set-TimeZone -Id $TimeZone
}

Set-ExactIPv4Configuration `
    -InterfaceIndex $adapter.ifIndex `
    -DesiredIPv4 $IPv4 `
    -DesiredPrefixLength $PrefixLength `
    -DesiredGateway $Gateway

Set-ExactDnsServers `
    -InterfaceIndex $adapter.ifIndex `
    -DesiredServers $DnsServers

if ($env:COMPUTERNAME -ne $TargetName) {
    Rename-Computer -NewName $TargetName -Force
    $restartRequired = $true
}

Install-RequiredFeatures

$summary = [pscustomobject]@{
    Hostname = $env:COMPUTERNAME
    TargetName = $TargetName
    Adapter = $adapter.Name
    IPv4 = $IPv4
    PrefixLength = $PrefixLength
    Gateway = $Gateway
    DnsServers = ($DnsServers -join ', ')
    RestartRequired = $restartRequired
}

$summary | Format-List | Out-String | Write-Output

if ($restartRequired -and $RestartIfNeeded) {
    Write-Output 'Restarting to complete hostname change.'
    Restart-Computer -Force
}

Full reference:

This script:

  • prompts the sysadmin for required deployment values if they are not provided as parameters
  • supports explicit parameter-based execution when the operator wants a non-interactive run
  • removes inherited incorrect default routes before applying the expected VLAN 20 gateway
  • installs AD-Domain-Services, DNS, and DHCP

Use a Windows time zone ID, not an IANA name. America/Santo_Domingo is valid in RouterOS, but not for Set-TimeZone on Windows Server. For the Dominican Republic baseline validated on July 16, 2026, use SA Western Standard Time. If the client uses a different region, check valid IDs first:

Get-TimeZone -ListAvailable | Sort-Object Id | Select-Object Id

Example local execution from an elevated PowerShell session on HQ-DC01:

.\Deploy-HQ-DC01-Stage1.ps1
  1. Reboot HQ-DC01, sign in again, and confirm the expected hostname, static IP, gateway, and DNS settings.

  2. Run the promotion script for the next stage:

.\Promote-HQ-DC01-Forest.ps1
Show full forest-promotion script
param(
    [string]$DomainName,
    [string]$NetBIOSName,
    [switch]$InstallDns = $true,
    [switch]$NoRebootOnCompletion,
    [switch]$ValidateOnly
)

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

function Read-RequiredValue {
    param(
        [string]$Prompt,
        [string]$DefaultValue
    )

    $fullPrompt = $Prompt
    if ($DefaultValue) {
        $fullPrompt = "$Prompt [$DefaultValue]"
    }

    while ($true) {
        $value = Read-Host -Prompt $fullPrompt
        if (-not [string]::IsNullOrWhiteSpace($value)) {
            return $value.Trim()
        }

        if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) {
            return $DefaultValue
        }
    }
}

function Read-RequiredSecret {
    param(
        [string]$Prompt
    )

    try {
        return (Read-Host -Prompt $Prompt -AsSecureString)
    }
    catch {
        throw "A secure value for '$Prompt' is required when running non-interactively."
    }
}

function Convert-SecureStringToLength {
    param(
        [Security.SecureString]$SecureValue
    )

    $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureValue)
    try {
        return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr).Length
    }
    finally {
        if ($bstr -ne [IntPtr]::Zero) {
            [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
        }
    }
}

function Resolve-Inputs {
    $script:DomainName = if ([string]::IsNullOrWhiteSpace($DomainName)) {
        Read-RequiredValue -Prompt 'Forest/domain FQDN' -DefaultValue 'corp.gntech.me'
    }
    else {
        $DomainName.Trim()
    }

    $script:NetBIOSName = if ([string]::IsNullOrWhiteSpace($NetBIOSName)) {
        Read-RequiredValue -Prompt 'NetBIOS name' -DefaultValue 'GNTECH'
    }
    else {
        $NetBIOSName.Trim()
    }

    if (-not $ValidateOnly) {
        $script:LocalAdministratorPassword = Read-RequiredSecret `
            -Prompt 'Local Administrator password'

        $script:SafeModeAdministratorPassword = Read-RequiredSecret `
            -Prompt 'DSRM password'
    }
}

function Test-Prerequisites {
    $computerSystem = Get-CimInstance Win32_ComputerSystem
    if ($computerSystem.PartOfDomain) {
        throw "Server is already joined to domain '$($computerSystem.Domain)'."
    }

    $addsFeature = Get-WindowsFeature AD-Domain-Services
    if ($addsFeature.InstallState -ne 'Installed') {
        throw 'AD-Domain-Services feature is not installed.'
    }

    $localAdministrator = Get-LocalUser | Where-Object SID -like 'S-1-5-21-*-500' | Select-Object -First 1
    if (-not $localAdministrator) {
        throw 'Built-in local Administrator account with RID 500 was not found.'
    }

    if (-not $ValidateOnly) {
        $localAdminLength = Convert-SecureStringToLength -SecureValue $script:LocalAdministratorPassword
        $safeModeLength = Convert-SecureStringToLength -SecureValue $script:SafeModeAdministratorPassword

        if ($localAdminLength -lt 12) {
            throw 'Local Administrator password must be at least 12 characters.'
        }

        if ($safeModeLength -lt 12) {
            throw 'DSRM password must be at least 12 characters.'
        }
    }

    [pscustomobject]@{
        Hostname = $env:COMPUTERNAME
        DomainName = $script:DomainName
        NetBIOSName = $script:NetBIOSName
        PartOfDomain = $computerSystem.PartOfDomain
        AddsInstalled = $addsFeature.InstallState
        InstallDns = [bool]$InstallDns
        NoRebootOnCompletion = [bool]$NoRebootOnCompletion
        ValidateOnly = [bool]$ValidateOnly
    }
}

Resolve-Inputs
$summary = Test-Prerequisites
$summary | Format-List | Out-String | Write-Output

if ($ValidateOnly) {
    Write-Output 'Validation mode only. Promotion was not started.'
    return
}

$localAdministrator = Get-LocalUser | Where-Object SID -like 'S-1-5-21-*-500' | Select-Object -First 1
$localAdministrator | Set-LocalUser -Password $script:LocalAdministratorPassword

$promotionParams = @{
    DomainName                    = $script:DomainName
    DomainNetbiosName             = $script:NetBIOSName
    SafeModeAdministratorPassword = $script:SafeModeAdministratorPassword
    InstallDns                    = [bool]$InstallDns
    Force                         = $true
    NoRebootOnCompletion          = [bool]$NoRebootOnCompletion
}

Install-ADDSForest @promotionParams

Full reference:

This script:

  • prompts for the domain FQDN, NetBIOS name, local Administrator password, and DSRM password when values are not provided
  • supports -ValidateOnly so the operator can confirm prereqs before starting promotion

Real password entry must happen securely at execution time from the sysadmin console on HQ-DC01. Do not add the local Administrator password or DSRM password as repository parameters, defaults, or committed values.

  1. Run a prereq check directly on HQ-DC01:
.\Promote-HQ-DC01-Forest.ps1 -ValidateOnly
  1. If prereqs pass, run the actual promotion directly on HQ-DC01:
.\Promote-HQ-DC01-Forest.ps1

The server will reboot as part of promotion.

  1. Sign in again after reboot using the domain credential format that succeeds in the environment, for example Administrator@corp.gntech.me.

  2. Run the post-promotion baseline script:

.\Configure-HQ-DC01-PostPromotion.ps1
Show full post-promotion script
param(
    [string]$DomainFqdn,
    [string]$DomainDn,
    [string]$DcFqdn,
    [string]$DcIp,
    [string]$UpnSuffix,
    [string[]]$DnsForwarders,
    [string]$ServerReverseNetworkId,
    [string]$WorkstationReverseNetworkId,
    [string]$DhcpScopeName,
    [string]$DhcpScopeId,
    [string]$DhcpStartRange,
    [string]$DhcpEndRange,
    [string]$DhcpSubnetMask,
    [string]$DhcpRouter,
    [string]$EnterpriseRootOuName,
    [string]$OuTemplateCsvPath,
    [switch]$ValidateOnly
)

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

function Read-RequiredValue {
    param(
        [string]$Prompt,
        [string]$DefaultValue
    )

    $fullPrompt = $Prompt
    if ($DefaultValue) {
        $fullPrompt = "$Prompt [$DefaultValue]"
    }

    while ($true) {
        $value = Read-Host -Prompt $fullPrompt
        if (-not [string]::IsNullOrWhiteSpace($value)) {
            return $value.Trim()
        }

        if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) {
            return $DefaultValue
        }
    }
}

function Read-StringList {
    param(
        [string]$Prompt,
        [string[]]$DefaultValues
    )

    while ($true) {
        $defaultValue = $DefaultValues -join ','
        $value = Read-RequiredValue -Prompt $Prompt -DefaultValue $defaultValue
        $items = @(
            $value.Split(',') |
            ForEach-Object { $_.Trim() } |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
        )

        if ($items.Count -gt 0) {
            return $items
        }
    }
}

function Resolve-Inputs {
    $script:DomainFqdn = if ([string]::IsNullOrWhiteSpace($DomainFqdn)) {
        Read-RequiredValue -Prompt 'Domain FQDN' -DefaultValue 'corp.gntech.me'
    } else { $DomainFqdn.Trim() }

    $script:DomainDn = if ([string]::IsNullOrWhiteSpace($DomainDn)) {
        Read-RequiredValue -Prompt 'Domain distinguished name' -DefaultValue 'DC=corp,DC=gntech,DC=me'
    } else { $DomainDn.Trim() }

    $script:DcFqdn = if ([string]::IsNullOrWhiteSpace($DcFqdn)) {
        Read-RequiredValue -Prompt 'Domain controller FQDN' -DefaultValue 'HQ-DC01.corp.gntech.me'
    } else { $DcFqdn.Trim() }

    $script:DcIp = if ([string]::IsNullOrWhiteSpace($DcIp)) {
        Read-RequiredValue -Prompt 'Domain controller IPv4' -DefaultValue '172.20.20.11'
    } else { $DcIp.Trim() }

    $script:UpnSuffix = if ([string]::IsNullOrWhiteSpace($UpnSuffix)) {
        Read-RequiredValue -Prompt 'User UPN suffix' -DefaultValue 'gntech.me'
    } else { $UpnSuffix.Trim() }

    $script:DnsForwarders = if ($DnsForwarders.Count -eq 0) {
        Read-StringList -Prompt 'DNS forwarders (comma-separated)' -DefaultValues @('1.1.1.1','9.9.9.9')
    } else {
        @($DnsForwarders | ForEach-Object { $_.Trim() } | Where-Object { $_ })
    }

    $script:ServerReverseNetworkId = if ([string]::IsNullOrWhiteSpace($ServerReverseNetworkId)) {
        Read-RequiredValue -Prompt 'Server reverse zone network' -DefaultValue '172.20.20.0/24'
    } else { $ServerReverseNetworkId.Trim() }

    $script:WorkstationReverseNetworkId = if ([string]::IsNullOrWhiteSpace($WorkstationReverseNetworkId)) {
        Read-RequiredValue -Prompt 'Workstation reverse zone network' -DefaultValue '172.20.30.0/24'
    } else { $WorkstationReverseNetworkId.Trim() }

    $script:DhcpScopeName = if ([string]::IsNullOrWhiteSpace($DhcpScopeName)) {
        Read-RequiredValue -Prompt 'DHCP scope name' -DefaultValue 'VLAN30 Workstations'
    } else { $DhcpScopeName.Trim() }

    $script:DhcpScopeId = if ([string]::IsNullOrWhiteSpace($DhcpScopeId)) {
        Read-RequiredValue -Prompt 'DHCP scope ID' -DefaultValue '172.20.30.0'
    } else { $DhcpScopeId.Trim() }

    $script:DhcpStartRange = if ([string]::IsNullOrWhiteSpace($DhcpStartRange)) {
        Read-RequiredValue -Prompt 'DHCP start range' -DefaultValue '172.20.30.100'
    } else { $DhcpStartRange.Trim() }

    $script:DhcpEndRange = if ([string]::IsNullOrWhiteSpace($DhcpEndRange)) {
        Read-RequiredValue -Prompt 'DHCP end range' -DefaultValue '172.20.30.199'
    } else { $DhcpEndRange.Trim() }

    $script:DhcpSubnetMask = if ([string]::IsNullOrWhiteSpace($DhcpSubnetMask)) {
        Read-RequiredValue -Prompt 'DHCP subnet mask' -DefaultValue '255.255.255.0'
    } else { $DhcpSubnetMask.Trim() }

    $script:DhcpRouter = if ([string]::IsNullOrWhiteSpace($DhcpRouter)) {
        Read-RequiredValue -Prompt 'DHCP router option' -DefaultValue '172.20.30.1'
    } else { $DhcpRouter.Trim() }

    $script:EnterpriseRootOuName = if ([string]::IsNullOrWhiteSpace($EnterpriseRootOuName)) {
        Read-RequiredValue -Prompt 'Enterprise root OU name' -DefaultValue 'GNTECH'
    } else { $EnterpriseRootOuName.Trim() }

    $script:OuTemplateCsvPath = if ([string]::IsNullOrWhiteSpace($OuTemplateCsvPath)) {
        $scriptDirectory = if (-not [string]::IsNullOrWhiteSpace($PSScriptRoot)) {
            $PSScriptRoot
        } else {
            (Get-Location).Path
        }

        $defaultCsv = Join-Path -Path $scriptDirectory -ChildPath 'ou-baseline.csv'
        if (Test-Path -LiteralPath $defaultCsv) { $defaultCsv } else { '' }
    } else { $OuTemplateCsvPath.Trim() }
}

function Ensure-Module {
    param([string]$Name)
    Import-Module $Name
}

function Ensure-UpnSuffix {
    $partitionsDn = "CN=Partitions,CN=Configuration,$script:DomainDn"
    $partitionsObject = Get-ADObject $partitionsDn -Properties uPNSuffixes
    if ($partitionsObject.uPNSuffixes -notcontains $script:UpnSuffix) {
        Set-ADObject $partitionsDn -Add @{ uPNSuffixes = $script:UpnSuffix }
    }
}

function Ensure-DnsForwarders {
    $existingForwarders = @(Get-DnsServerForwarder -ErrorAction SilentlyContinue | ForEach-Object {
        if ($_.IPAddress -is [array]) {
            $_.IPAddress | ForEach-Object { $_.IPAddressToString }
        }
        elseif ($_.IPAddress) {
            $_.IPAddress.IPAddressToString
        }
    })

    foreach ($forwarder in $script:DnsForwarders) {
        if ($existingForwarders -notcontains $forwarder) {
            Add-DnsServerForwarder -IPAddress $forwarder
        }
    }
}

function Ensure-DnsZone {
    param([string]$NetworkId)
    $networkAddress = $NetworkId.Split('/')[0]
    $octets = $networkAddress.Split('.')
    $zoneName = "$($octets[2]).$($octets[1]).$($octets[0]).in-addr.arpa"

    if (-not (Get-DnsServerZone -Name $zoneName -ErrorAction SilentlyContinue)) {
        Add-DnsServerPrimaryZone -NetworkId $NetworkId -ReplicationScope Forest
    }
}

function Ensure-DhcpAuthorization {
    $authorized = @(Get-DhcpServerInDC -ErrorAction SilentlyContinue)
    if (-not ($authorized | Where-Object DnsName -eq $script:DcFqdn)) {
        Add-DhcpServerInDC -DnsName $script:DcFqdn -IpAddress $script:DcIp
    }

    & netsh dhcp add securitygroups | Out-Null
    Restart-Service DHCPServer -Force
    Start-Sleep -Seconds 10
}

function Ensure-DhcpScope {
    if (-not (Get-DhcpServerv4Scope -ErrorAction SilentlyContinue | Where-Object ScopeId -eq $script:DhcpScopeId)) {
        Add-DhcpServerv4Scope `
            -Name $script:DhcpScopeName `
            -StartRange $script:DhcpStartRange `
            -EndRange $script:DhcpEndRange `
            -SubnetMask $script:DhcpSubnetMask `
            -State Active
    }

    Set-DhcpServerv4OptionValue `
        -ScopeId $script:DhcpScopeId `
        -Router $script:DhcpRouter `
        -DnsServer $script:DcIp `
        -DnsDomain $script:DomainFqdn

    Set-DhcpServerv4DnsSetting `
        -DynamicUpdates Always `
        -DeleteDnsRRonLeaseExpiry $true `
        -UpdateDnsRRForOlderClients $true
}

function Ensure-OU {
    param(
        [string]$Name,
        [string]$Path,
        [bool]$ProtectedFromAccidentalDeletion = $true
    )

    $ouDn = "OU=$Name,$Path"
    if (-not (Get-ADOrganizationalUnit -LDAPFilter "(distinguishedName=$ouDn)" -ErrorAction SilentlyContinue)) {
        New-ADOrganizationalUnit -Name $Name -Path $Path -ProtectedFromAccidentalDeletion $ProtectedFromAccidentalDeletion
    }
}

function Get-DefaultOuDefinitions {
    $rootDn = "OU=$($script:EnterpriseRootOuName),$($script:DomainDn)"

    @(
        @{ Name = $script:EnterpriseRootOuName; ParentDn = $script:DomainDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Tier0'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Tier1'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Tier2'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Users'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Workstations'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Servers'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Groups'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'ServiceAccounts'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Admins'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Sites'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Staging'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Quarantine'; ParentDn = $rootDn; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Corporate'; ParentDn = "OU=Users,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Regional'; ParentDn = "OU=Users,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Contractors'; ParentDn = "OU=Users,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'PrivilegedExcluded'; ParentDn = "OU=Users,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Disabled'; ParentDn = "OU=Users,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'AMER'; ParentDn = "OU=Workstations,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'EMEA'; ParentDn = "OU=Workstations,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'APAC'; ParentDn = "OU=Workstations,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Kiosks'; ParentDn = "OU=Workstations,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'VDI'; ParentDn = "OU=Workstations,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Quarantine'; ParentDn = "OU=Workstations,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Infrastructure'; ParentDn = "OU=Servers,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Applications'; ParentDn = "OU=Servers,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Regional'; ParentDn = "OU=Servers,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'JumpHosts'; ParentDn = "OU=Servers,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'RoleBased'; ParentDn = "OU=Groups,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'ResourceBased'; ParentDn = "OU=Groups,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'MailEnabled'; ParentDn = "OU=Groups,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Delegation'; ParentDn = "OU=Groups,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Tier0'; ParentDn = "OU=ServiceAccounts,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Tier1'; ParentDn = "OU=ServiceAccounts,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Tier2'; ParentDn = "OU=ServiceAccounts,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Applications'; ParentDn = "OU=ServiceAccounts,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Tier0'; ParentDn = "OU=Admins,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Tier1'; ParentDn = "OU=Admins,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Tier2'; ParentDn = "OU=Admins,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'EmergencyAccess'; ParentDn = "OU=Admins,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'AMER'; ParentDn = "OU=Sites,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'EMEA'; ParentDn = "OU=Sites,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'APAC'; ParentDn = "OU=Sites,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'AdminGroups'; ParentDn = "OU=Tier0,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Delegation'; ParentDn = "OU=Tier0,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'PrivilegedRoles'; ParentDn = "OU=Tier0,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'AdminGroups'; ParentDn = "OU=Tier1,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Delegation'; ParentDn = "OU=Tier1,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'PrivilegedRoles'; ParentDn = "OU=Tier1,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'AdminGroups'; ParentDn = "OU=Tier2,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'Delegation'; ParentDn = "OU=Tier2,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
        @{ Name = 'PrivilegedRoles'; ParentDn = "OU=Tier2,$rootDn"; ProtectedFromAccidentalDeletion = $true; Enabled = $true }
    )
}

function Get-OuDefinitions {
    if (-not [string]::IsNullOrWhiteSpace($script:OuTemplateCsvPath)) {
        if (-not (Test-Path -LiteralPath $script:OuTemplateCsvPath)) {
            throw "OU template CSV '$($script:OuTemplateCsvPath)' was not found."
        }

        return @(Import-Csv -LiteralPath $script:OuTemplateCsvPath | Where-Object {
            $_.Enabled -match '^(?i:true|1|yes)$'
        })
    }

    return @(Get-DefaultOuDefinitions)
}

function Ensure-OuHierarchy {
    $definitions = @(Get-OuDefinitions)
    foreach ($definition in $definitions) {
        $protected = $true
        if ($definition.PSObject.Properties.Name -contains 'ProtectedFromAccidentalDeletion') {
            $protected = $definition.ProtectedFromAccidentalDeletion -match '^(?i:true|1|yes)$'
        }

        Ensure-OU -Name $definition.Name -Path $definition.ParentDn -ProtectedFromAccidentalDeletion $protected
    }
}

Resolve-Inputs
Ensure-Module -Name ActiveDirectory
Ensure-Module -Name DhcpServer
Ensure-Module -Name DnsServer

$summary = [pscustomobject]@{
    DomainFqdn = $script:DomainFqdn
    DomainDn = $script:DomainDn
    DcFqdn = $script:DcFqdn
    DcIp = $script:DcIp
    UpnSuffix = $script:UpnSuffix
    DnsForwarders = ($script:DnsForwarders -join ', ')
    ServerReverseNetworkId = $script:ServerReverseNetworkId
    WorkstationReverseNetworkId = $script:WorkstationReverseNetworkId
    DhcpScopeId = $script:DhcpScopeId
    DhcpRouter = $script:DhcpRouter
    EnterpriseRootOuName = $script:EnterpriseRootOuName
    OuTemplateCsvPath = $(if ([string]::IsNullOrWhiteSpace($script:OuTemplateCsvPath)) { 'Built-in default hierarchy' } else { $script:OuTemplateCsvPath })
    ValidateOnly = [bool]$ValidateOnly
}

$summary | Format-List | Out-String | Write-Output

if ($ValidateOnly) {
    Write-Output 'Validation mode only. Post-promotion baseline was not applied.'
    return
}

Ensure-UpnSuffix
Ensure-DnsForwarders
Ensure-DnsZone -NetworkId $script:ServerReverseNetworkId
Ensure-DnsZone -NetworkId $script:WorkstationReverseNetworkId
Ensure-DhcpAuthorization
Ensure-DhcpScope
Ensure-OuHierarchy

Write-Output 'Post-promotion baseline completed successfully.'
  • authorizes DHCP in AD, refreshes DHCP security groups, and creates the validated workstation scope
  • creates the validated GNTECH OU hierarchy used in this lab
  • can read the OU hierarchy from a CSV template when the sysadmin wants a customer-specific OU baseline without editing the script

The OU hierarchy is applied in this step. It is not a separate later runbook.

CSV-driven OU option:

  • template file: data/ou-baseline.csv
  • script parameter: -OuTemplateCsvPath
  • if the sysadmin places ou-baseline.csv next to Configure-HQ-DC01-PostPromotion.ps1, the script can use that file directly
  • if no CSV is supplied, the script falls back to the validated built-in OU hierarchy used by the lab

Full reference:

Top-level OUs created by the script under OU=GNTECH:

  • Tier0
  • Tier1
  • Tier2
  • Users
  • Workstations
  • Servers
  • Groups
  • ServiceAccounts
  • Admins
  • Sites
  • Staging
  • Quarantine

  • Execute the post-promotion script locally on HQ-DC01:

.\Configure-HQ-DC01-PostPromotion.ps1

Example with an explicit OU template CSV:

.\Configure-HQ-DC01-PostPromotion.ps1 -OuTemplateCsvPath 'C:\Deploy\ou-baseline.csv'
  1. Validate the new domain controller by running the validation script locally:
.\Validate-HQ-DC01.ps1
Show full DC01 validation script
param(
    [string]$ExpectedHostname,
    [string]$ExpectedDomainFqdn,
    [string]$ExpectedNetBIOSName,
    [string]$ExpectedUpnSuffix,
    [string]$ExpectedDcIp,
    [string[]]$ExpectedDnsForwarders,
    [string[]]$ExpectedDnsZones,
    [string]$ExpectedDhcpAuthorizedDnsName,
    [string]$ExpectedDhcpAuthorizedIp,
    [string]$ExpectedDhcpScopeId,
    [string]$ExpectedEnterpriseRootOuDn,
    [string]$OuTemplateCsvPath,
    [string[]]$ExpectedOuDns
)

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

function Read-RequiredValue {
    param(
        [string]$Prompt,
        [string]$DefaultValue
    )

    $fullPrompt = $Prompt
    if ($DefaultValue) {
        $fullPrompt = "$Prompt [$DefaultValue]"
    }

    while ($true) {
        $value = Read-Host -Prompt $fullPrompt
        if (-not [string]::IsNullOrWhiteSpace($value)) {
            return $value.Trim()
        }

        if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) {
            return $DefaultValue
        }
    }
}

function Read-StringList {
    param(
        [string]$Prompt,
        [string[]]$DefaultValues
    )

    while ($true) {
        $defaultValue = $DefaultValues -join ','
        $value = Read-RequiredValue -Prompt $Prompt -DefaultValue $defaultValue
        $items = @(
            $value.Split(',') |
            ForEach-Object { $_.Trim() } |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
        )

        if ($items.Count -gt 0) {
            return $items
        }
    }
}

function Resolve-Inputs {
    $script:ExpectedHostname = if ([string]::IsNullOrWhiteSpace($ExpectedHostname)) {
        Read-RequiredValue -Prompt 'Expected hostname' -DefaultValue 'HQ-DC01'
    } else { $ExpectedHostname.Trim() }

    $script:ExpectedDomainFqdn = if ([string]::IsNullOrWhiteSpace($ExpectedDomainFqdn)) {
        Read-RequiredValue -Prompt 'Expected domain FQDN' -DefaultValue 'corp.gntech.me'
    } else { $ExpectedDomainFqdn.Trim() }

    $script:ExpectedNetBIOSName = if ([string]::IsNullOrWhiteSpace($ExpectedNetBIOSName)) {
        Read-RequiredValue -Prompt 'Expected NetBIOS name' -DefaultValue 'GNTECH'
    } else { $ExpectedNetBIOSName.Trim() }

    $script:ExpectedUpnSuffix = if ([string]::IsNullOrWhiteSpace($ExpectedUpnSuffix)) {
        Read-RequiredValue -Prompt 'Expected UPN suffix' -DefaultValue 'gntech.me'
    } else { $ExpectedUpnSuffix.Trim() }

    $script:ExpectedDcIp = if ([string]::IsNullOrWhiteSpace($ExpectedDcIp)) {
        Read-RequiredValue -Prompt 'Expected DC IPv4' -DefaultValue '172.20.20.11'
    } else { $ExpectedDcIp.Trim() }

    $script:ExpectedDnsForwarders = if ($ExpectedDnsForwarders.Count -eq 0) {
        Read-StringList -Prompt 'Expected DNS forwarders (comma-separated)' -DefaultValues @('1.1.1.1','9.9.9.9')
    } else {
        @($ExpectedDnsForwarders | ForEach-Object { $_.Trim() } | Where-Object { $_ })
    }

    $script:ExpectedDnsZones = if ($ExpectedDnsZones.Count -eq 0) {
        Read-StringList -Prompt 'Expected DNS zones (comma-separated)' -DefaultValues @('corp.gntech.me','20.20.172.in-addr.arpa','30.20.172.in-addr.arpa')
    } else {
        @($ExpectedDnsZones | ForEach-Object { $_.Trim() } | Where-Object { $_ })
    }

    $script:ExpectedDhcpAuthorizedDnsName = if ([string]::IsNullOrWhiteSpace($ExpectedDhcpAuthorizedDnsName)) {
        Read-RequiredValue -Prompt 'Expected DHCP authorized DNS name' -DefaultValue 'hq-dc01.corp.gntech.me'
    } else { $ExpectedDhcpAuthorizedDnsName.Trim() }

    $script:ExpectedDhcpAuthorizedIp = if ([string]::IsNullOrWhiteSpace($ExpectedDhcpAuthorizedIp)) {
        Read-RequiredValue -Prompt 'Expected DHCP authorized IP' -DefaultValue '172.20.20.11'
    } else { $ExpectedDhcpAuthorizedIp.Trim() }

    $script:ExpectedDhcpScopeId = if ([string]::IsNullOrWhiteSpace($ExpectedDhcpScopeId)) {
        Read-RequiredValue -Prompt 'Expected DHCP scope ID' -DefaultValue '172.20.30.0'
    } else { $ExpectedDhcpScopeId.Trim() }

    $script:ExpectedEnterpriseRootOuDn = if ([string]::IsNullOrWhiteSpace($ExpectedEnterpriseRootOuDn)) {
        Read-RequiredValue -Prompt 'Expected enterprise root OU DN' -DefaultValue 'OU=GNTECH,DC=corp,DC=gntech,DC=me'
    } else { $ExpectedEnterpriseRootOuDn.Trim() }

    $script:OuTemplateCsvPath = if ([string]::IsNullOrWhiteSpace($OuTemplateCsvPath)) {
        $scriptDirectory = if (-not [string]::IsNullOrWhiteSpace($PSScriptRoot)) {
            $PSScriptRoot
        } else {
            (Get-Location).Path
        }

        $defaultCsv = Join-Path -Path $scriptDirectory -ChildPath 'ou-baseline.csv'
        if (Test-Path -LiteralPath $defaultCsv) { $defaultCsv } else { '' }
    } else { $OuTemplateCsvPath.Trim() }

    $script:ExpectedOuDns = if ($ExpectedOuDns.Count -eq 0 -and [string]::IsNullOrWhiteSpace($script:OuTemplateCsvPath)) {
        @(
            'OU=Tier0,OU=GNTECH,DC=corp,DC=gntech,DC=me',
            'OU=Tier1,OU=GNTECH,DC=corp,DC=gntech,DC=me',
            'OU=Tier2,OU=GNTECH,DC=corp,DC=gntech,DC=me',
            'OU=Users,OU=GNTECH,DC=corp,DC=gntech,DC=me',
            'OU=Workstations,OU=GNTECH,DC=corp,DC=gntech,DC=me',
            'OU=Servers,OU=GNTECH,DC=corp,DC=gntech,DC=me',
            'OU=Groups,OU=GNTECH,DC=corp,DC=gntech,DC=me',
            'OU=ServiceAccounts,OU=GNTECH,DC=corp,DC=gntech,DC=me',
            'OU=Admins,OU=GNTECH,DC=corp,DC=gntech,DC=me',
            'OU=Sites,OU=GNTECH,DC=corp,DC=gntech,DC=me'
        )
    } else {
        @($ExpectedOuDns | ForEach-Object { $_.Trim() } | Where-Object { $_ })
    }
}

function Ensure-Module {
    param([string]$Name)
    Import-Module $Name
}

function Get-ForwarderStrings {
    @(Get-DnsServerForwarder -ErrorAction SilentlyContinue | ForEach-Object {
        if ($_.IPAddress -is [array]) {
            $_.IPAddress | ForEach-Object { $_.IPAddressToString }
        }
        elseif ($_.IPAddress) {
            $_.IPAddress.IPAddressToString
        }
    })
}

function Get-ExpectedOuDnsFromCsv {
    param([string]$Path)

    if (-not (Test-Path -LiteralPath $Path)) {
        throw "OU template CSV '$Path' was not found."
    }

    @(Import-Csv -LiteralPath $Path | Where-Object {
        $_.Enabled -match '^(?i:true|1|yes)$'
    } | ForEach-Object {
        "OU=$($_.Name),$($_.ParentDn)"
    })
}

Resolve-Inputs
Ensure-Module -Name ActiveDirectory
Ensure-Module -Name DhcpServer
Ensure-Module -Name DnsServer

if (-not [string]::IsNullOrWhiteSpace($script:OuTemplateCsvPath)) {
    $script:ExpectedOuDns = @(Get-ExpectedOuDnsFromCsv -Path $script:OuTemplateCsvPath | Where-Object { $_ -ne $script:ExpectedEnterpriseRootOuDn })
}

$forest = Get-ADForest
$domain = Get-ADDomain
$computerSystem = Get-CimInstance Win32_ComputerSystem
$features = Get-WindowsFeature AD-Domain-Services, DNS, DHCP
$services = Get-Service NTDS, DNS, DHCPServer
$forwarders = Get-ForwarderStrings
$zones = @(Get-DnsServerZone -ErrorAction SilentlyContinue | Where-Object ZoneName -in $script:ExpectedDnsZones)
$authorizedDhcp = @(Get-DhcpServerInDC -ErrorAction SilentlyContinue)
$scope = Get-DhcpServerv4Scope -ScopeId $script:ExpectedDhcpScopeId -ErrorAction SilentlyContinue
$scopeOptions = @(Get-DhcpServerv4OptionValue -ScopeId $script:ExpectedDhcpScopeId -ErrorAction SilentlyContinue)
$rootOu = Get-ADOrganizationalUnit -LDAPFilter "(distinguishedName=$($script:ExpectedEnterpriseRootOuDn))" -ErrorAction SilentlyContinue
$expectedOusFound = @()
foreach ($ouDn in $script:ExpectedOuDns) {
    $found = Get-ADOrganizationalUnit -LDAPFilter "(distinguishedName=$ouDn)" -ErrorAction SilentlyContinue
    if ($found) {
        $expectedOusFound += $ouDn
    }
}

Write-Output '=== Identity ==='
Write-Output "Hostname: $env:COMPUTERNAME"
Write-Output "PartOfDomain: $($computerSystem.PartOfDomain)"
Write-Output "ComputerSystemDomain: $($computerSystem.Domain)"
Write-Output "ForestRootDomain: $($forest.RootDomain)"
Write-Output "ForestMode: $($forest.ForestMode)"
Write-Output "UPNSuffixes: $($forest.UPNSuffixes -join ', ')"
Write-Output "DomainDNSRoot: $($domain.DNSRoot)"
Write-Output "DomainNetBIOSName: $($domain.NetBIOSName)"
Write-Output "DomainMode: $($domain.DomainMode)"

Write-Output '=== Features ==='
foreach ($feature in $features) {
    Write-Output "$($feature.Name): $($feature.InstallState)"
}

Write-Output '=== Services ==='
foreach ($service in $services) {
    Write-Output "$($service.Name): $($service.Status)"
}

Write-Output '=== DNS ==='
if ($forwarders.Count -eq 0) {
    Write-Output 'Forwarders: none found'
} else {
    foreach ($forwarder in $forwarders) {
        Write-Output "Forwarder: $forwarder"
    }
}

if ($zones.Count -eq 0) {
    Write-Output 'Zones: none found'
} else {
    foreach ($zone in $zones) {
        Write-Output "Zone: $($zone.ZoneName) / Type=$($zone.ZoneType) / DsIntegrated=$($zone.IsDsIntegrated)"
    }
}

Write-Output '=== DHCP ==='
if ($authorizedDhcp.Count -eq 0) {
    Write-Output 'Authorized DHCP servers: none found'
} else {
    foreach ($entry in $authorizedDhcp) {
        Write-Output "Authorized DHCP: $($entry.DnsName) / $($entry.IPAddress.IPAddressToString)"
    }
}

if (-not $scope) {
    Write-Output "Scope $($script:ExpectedDhcpScopeId): missing"
} else {
    Write-Output "Scope: $($scope.ScopeId.IPAddressToString) / Name=$($scope.Name) / State=$($scope.State) / Start=$($scope.StartRange.IPAddressToString) / End=$($scope.EndRange.IPAddressToString)"
}

if ($scopeOptions.Count -eq 0) {
    Write-Output "DHCP options for scope $($script:ExpectedDhcpScopeId): none found"
} else {
    foreach ($option in $scopeOptions) {
        $valueText = if ($option.Value -is [array]) { $option.Value -join ', ' } else { $option.Value }
        Write-Output "DHCP Option $($option.OptionId): $($option.Name) = $valueText"
    }
}

Write-Output '=== OU Structure ==='
if (-not $rootOu) {
    Write-Output "Enterprise root OU missing: $($script:ExpectedEnterpriseRootOuDn)"
} else {
    Write-Output "Enterprise root OU: $($rootOu.DistinguishedName)"
}

foreach ($ouDn in $script:ExpectedOuDns) {
    if ($expectedOusFound -contains $ouDn) {
        Write-Output "OU Present: $ouDn"
    } else {
        Write-Output "OU Missing: $ouDn"
    }
}

Full reference:

This script validates the live baseline that was actually applied on Thursday, July 16, 2026:

  • corp.gntech.me forest and domain state
  • installed AD DS, DNS, and DHCP
  • running NTDS, DNS, and DHCPServer
  • forwarders 1.1.1.1 and 9.9.9.9
  • reverse zones 172.20.20.0/24 and 172.20.30.0/24
  • authorized DHCP service and scope 172.20.30.0
  • object layout rooted at OU=GNTECH

Validation

  • HQ-DC01 boots with the expected hostname and static IP.
  • corp.gntech.me exists as the new forest root domain.
  • gntech.me exists as an alternate UPN suffix.
  • dcdiag /test:Advertising /test:Services /test:SysVolCheck /test:NetLogons reports no blocking errors.
  • DNS forwarders and reverse lookup zones are present.
  • DHCP is authorized and the VLAN 30 scope is active.
  • OU=GNTECH exists with the validated day-zero object layout applied in this lab.
  • NETLOGON and SYSVOL shares are present.
  • DFSREvent warnings are treated as non-blocking only if SYSVOL and NETLOGON are already shared and the DFSR service is healthy. Re-test after convergence before declaring the build complete.
  • HQ-DC01 is ready for immediate post-deploy identity seeding such as test users, admin users, and service accounts.

Evidence

  • ipconfig /all
  • Get-WindowsFeature AD-Domain-Services, DNS, DHCP
  • dcdiag /test:Advertising /test:Services /test:SysVolCheck /test:NetLogons
  • Get-DhcpServerv4Scope
  • Get-ADForest
  • Get-ADDomain
  • Get-DnsServerForwarder
  • Get-DnsServerZone
  • Get-DhcpServerInDC
  • Get-ADOrganizationalUnit
  • Get-Service DFSR, DNS, DHCPServer
  • Get-WinEvent -LogName 'DFS Replication'

Post-deploy follow-on work

After HQ-DC01 passes validation, create the first test identities directly on HQ-DC01 before continuing to file-share and workstation validation.

Run the test-user creation script locally from an elevated PowerShell session on HQ-DC01:

.\Create-HQ-TestUsers.ps1
Show full test-user creation script
param(
    [string]$DomainDn,
    [string]$UsersOuDn,
    [string]$AdminsOuDn,
    [string]$UserUpnSuffix,
    [string]$AdminUpnSuffix,
    [string]$PublicWriteGroupSamAccountName,
    [switch]$ValidateOnly
)

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

function Read-RequiredValue {
    param(
        [string]$Prompt,
        [string]$DefaultValue
    )

    $fullPrompt = $Prompt
    if ($DefaultValue) { $fullPrompt = "$Prompt [$DefaultValue]" }

    while ($true) {
        $value = Read-Host -Prompt $fullPrompt
        if (-not [string]::IsNullOrWhiteSpace($value)) { return $value.Trim() }
        if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) { return $DefaultValue }
    }
}

function Read-RequiredSecret {
    param([string]$Prompt)

    try {
        return Read-Host -Prompt $Prompt -AsSecureString
    }
    catch {
        throw "A secure value for '$Prompt' is required when running non-interactively."
    }
}

function Resolve-Inputs {
    $script:DomainDn = if ([string]::IsNullOrWhiteSpace($DomainDn)) { Read-RequiredValue 'Domain DN' 'DC=corp,DC=gntech,DC=me' } else { $DomainDn.Trim() }
    $script:UsersOuDn = if ([string]::IsNullOrWhiteSpace($UsersOuDn)) { Read-RequiredValue 'Users OU DN' 'OU=Users,OU=GNTECH,DC=corp,DC=gntech,DC=me' } else { $UsersOuDn.Trim() }
    $script:AdminsOuDn = if ([string]::IsNullOrWhiteSpace($AdminsOuDn)) { Read-RequiredValue 'Admins OU DN' 'OU=Admins,OU=GNTECH,DC=corp,DC=gntech,DC=me' } else { $AdminsOuDn.Trim() }
    $script:UserUpnSuffix = if ([string]::IsNullOrWhiteSpace($UserUpnSuffix)) { Read-RequiredValue 'Standard user UPN suffix' 'gntech.me' } else { $UserUpnSuffix.Trim() }
    $script:AdminUpnSuffix = if ([string]::IsNullOrWhiteSpace($AdminUpnSuffix)) { Read-RequiredValue 'Admin user UPN suffix' 'gntech.me' } else { $AdminUpnSuffix.Trim() }
    $script:PublicWriteGroupSamAccountName = if ([string]::IsNullOrWhiteSpace($PublicWriteGroupSamAccountName)) { Read-RequiredValue 'Public write group SamAccountName' 'DL-HQ-FS01-Public-RW' } else { $PublicWriteGroupSamAccountName.Trim() }

    if (-not $ValidateOnly) {
        $script:DefaultUserPassword = Read-RequiredSecret -Prompt 'Standard user password'
        $script:DefaultAdminPassword = Read-RequiredSecret -Prompt 'Admin user password'
    }
}

function Ensure-Module {
    param([string]$Name)
    Import-Module $Name
}

function Ensure-TestUser {
    param(
        [string]$Name,
        [string]$GivenName,
        [string]$Surname,
        [string]$DisplayName,
        [string]$UserPrincipalName,
        [string]$SamAccountName,
        [string]$EmployeeId,
        [string]$Path,
        [securestring]$Password
    )

    $existing = Get-ADUser -Filter "SamAccountName -eq '$SamAccountName'" -ErrorAction SilentlyContinue
    if (-not $existing) {
        New-ADUser `
            -Name $Name `
            -GivenName $GivenName `
            -Surname $Surname `
            -DisplayName $DisplayName `
            -UserPrincipalName $UserPrincipalName `
            -SamAccountName $SamAccountName `
            -EmployeeID $EmployeeId `
            -Path $Path `
            -AccountPassword $Password `
            -Enabled $true `
            -ChangePasswordAtLogon $false
    }
}

function Ensure-GroupMembership {
    param(
        [string]$GroupSamAccountName,
        [string]$MemberSamAccountName
    )

    $group = Get-ADGroup -Filter "SamAccountName -eq '$GroupSamAccountName'" -ErrorAction SilentlyContinue
    if (-not $group) {
        throw "The group '$GroupSamAccountName' was not found."
    }

    $member = Get-ADUser -Filter "SamAccountName -eq '$MemberSamAccountName'" -ErrorAction SilentlyContinue
    if (-not $member) {
        throw "The user '$MemberSamAccountName' was not found."
    }

    $isMember = Get-ADGroupMember -Identity $group.DistinguishedName -Recursive | Where-Object SamAccountName -eq $MemberSamAccountName
    if ($isMember) {
        return [pscustomobject]@{
            GroupSamAccountName = $GroupSamAccountName
            MemberSamAccountName = $MemberSamAccountName
            Action = 'Exists'
        }
    }

    if (-not $ValidateOnly) {
        Add-ADGroupMember -Identity $group.DistinguishedName -Members $member.DistinguishedName
    }

    [pscustomobject]@{
        GroupSamAccountName = $GroupSamAccountName
        MemberSamAccountName = $MemberSamAccountName
        Action = $(if ($ValidateOnly) { 'Planned' } else { 'Added' })
    }
}

Resolve-Inputs
Ensure-Module -Name ActiveDirectory

$definitions = @(
    @{
        Name = 'Miguel Perez'
        GivenName = 'Miguel'
        Surname = 'Perez'
        DisplayName = 'Miguel Perez'
        UserPrincipalName = "miguel.perez@$($script:UserUpnSuffix)"
        SamAccountName = 'miguel.perez'
        EmployeeId = 'GNT-000123'
        Path = $script:UsersOuDn
        IsAdmin = $false
    },
    @{
        Name = 'Ana Garcia'
        GivenName = 'Ana'
        Surname = 'Garcia'
        DisplayName = 'Ana Garcia'
        UserPrincipalName = "ana.garcia@$($script:UserUpnSuffix)"
        SamAccountName = 'ana.garcia'
        EmployeeId = 'GNT-000124'
        Path = $script:UsersOuDn
        IsAdmin = $false
    },
    @{
        Name = 'Admin Miguel Perez'
        GivenName = 'Miguel'
        Surname = 'Perez'
        DisplayName = 'Admin Miguel Perez'
        UserPrincipalName = "adm.miguel.perez@$($script:AdminUpnSuffix)"
        SamAccountName = 'adm.miguel.perez'
        EmployeeId = 'GNT-A000123'
        Path = $script:AdminsOuDn
        IsAdmin = $true
    },
    @{
        Name = 'Admin Gerlin Nolasco'
        GivenName = 'Gerlin'
        Surname = 'Nolasco'
        DisplayName = 'Admin Gerlin Nolasco'
        UserPrincipalName = "adm.gerlin.nolasco@$($script:AdminUpnSuffix)"
        SamAccountName = 'adm.gerlin.nolasco'
        EmployeeId = 'GNT-A000124'
        Path = $script:AdminsOuDn
        IsAdmin = $true
    }
)

$summary = $definitions | ForEach-Object {
    [pscustomobject]@{
        Name = $_.Name
        UserPrincipalName = $_.UserPrincipalName
        SamAccountName = $_.SamAccountName
        EmployeeId = $_.EmployeeId
        Path = $_.Path
    }
}
$summary | Format-Table -AutoSize | Out-String | Write-Output

[pscustomobject]@{
    PublicWriteGroupSamAccountName = $script:PublicWriteGroupSamAccountName
    PublicWriteMemberSamAccountName = 'miguel.perez'
    PublicDeniedMemberSamAccountName = 'ana.garcia'
} | Format-List | Out-String | Write-Output

if ($ValidateOnly) {
    Write-Output 'Validation mode only. Test users and group memberships were not created.'
    return
}

foreach ($definition in $definitions) {
    $password = if ($definition.IsAdmin) { $script:DefaultAdminPassword } else { $script:DefaultUserPassword }
    Ensure-TestUser `
        -Name $definition.Name `
        -GivenName $definition.GivenName `
        -Surname $definition.Surname `
        -DisplayName $definition.DisplayName `
        -UserPrincipalName $definition.UserPrincipalName `
        -SamAccountName $definition.SamAccountName `
        -EmployeeId $definition.EmployeeId `
        -Path $definition.Path `
        -Password $password
}

$membershipResult = Ensure-GroupMembership `
    -GroupSamAccountName $script:PublicWriteGroupSamAccountName `
    -MemberSamAccountName 'miguel.perez'

$membershipResult | Format-Table -AutoSize | Out-String | Write-Output

Write-Output 'Test users completed successfully.'

Full reference:

Why here:

  • the users depend on the domain and OU baseline created by HQ-DC01
  • they are consumed later by P03-T07 - HQ-FS01 File Services and P03-T08 - Workstation Resource Access Validation
  • they should not be introduced first from HQ-FS01 or a workstation runbook

Create the baseline service identities next, from the same elevated session on HQ-DC01:

.\Create-HQ-ServiceAccounts.ps1 -ServiceUpnSuffix 'gntech.me'

The script validates the domain and target OU first, securely prompts only for accounts that do not already exist, and never stores passwords in the repository. Existing accounts are checked for the expected OU and metadata; the script does not reset their passwords on a re-run.

Show full service-account script
param(
    [string]$DomainDn,
    [string]$ServiceAccountsOuDn,
    [string]$ServiceUpnSuffix,
    [hashtable]$AccountPasswords,
    [switch]$PasswordNeverExpires,
    [switch]$ValidateOnly
)

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

function Assert-Elevated {
    $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = New-Object Security.Principal.WindowsPrincipal($identity)
    if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
        throw 'Run this script from an elevated PowerShell session.'
    }
}

function Read-RequiredValue {
    param(
        [string]$Prompt,
        [string]$DefaultValue
    )

    $fullPrompt = $Prompt
    if ($DefaultValue) {
        $fullPrompt = "$Prompt [$DefaultValue]"
    }

    while ($true) {
        $value = Read-Host -Prompt $fullPrompt
        if (-not [string]::IsNullOrWhiteSpace($value)) {
            return $value.Trim()
        }

        if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) {
            return $DefaultValue
        }
    }
}

function Read-RequiredSecret {
    param([string]$Prompt)

    try {
        return Read-Host -Prompt $Prompt -AsSecureString
    }
    catch {
        throw "A secure value for '$Prompt' is required."
    }
}

function Resolve-Inputs {
    $script:DomainDn = if ([string]::IsNullOrWhiteSpace($DomainDn)) {
        Read-RequiredValue -Prompt 'Domain DN' -DefaultValue 'DC=corp,DC=gntech,DC=me'
    }
    else {
        $DomainDn.Trim()
    }

    $script:ServiceAccountsOuDn = if ([string]::IsNullOrWhiteSpace($ServiceAccountsOuDn)) {
        Read-RequiredValue -Prompt 'Service accounts OU DN' -DefaultValue 'OU=Tier1,OU=ServiceAccounts,OU=GNTECH,DC=corp,DC=gntech,DC=me'
    }
    else {
        $ServiceAccountsOuDn.Trim()
    }

    $script:ServiceUpnSuffix = if ([string]::IsNullOrWhiteSpace($ServiceUpnSuffix)) {
        ''
    }
    else {
        $ServiceUpnSuffix.Trim()
    }
}

function Ensure-Module {
    param([string]$Name)
    Import-Module $Name
}

function Assert-AdTargets {
    $domain = Get-ADDomain -ErrorAction Stop
    if ($domain.DistinguishedName -ne $script:DomainDn) {
        throw "Connected domain '$($domain.DistinguishedName)' does not match requested domain '$($script:DomainDn)'."
    }
    Get-ADOrganizationalUnit -Identity $script:ServiceAccountsOuDn -ErrorAction Stop | Out-Null
}

function Get-ServiceAccountUpn {
    param([string]$SamAccountName)

    if ([string]::IsNullOrWhiteSpace($script:ServiceUpnSuffix)) {
        return $null
    }

    return "$SamAccountName@$($script:ServiceUpnSuffix)"
}

function Ensure-ServiceAccount {
    param(
        [hashtable]$Definition,
        [securestring]$Password
    )

    $existing = Get-ADUser -Filter "SamAccountName -eq '$($Definition.SamAccountName)'" -Properties UserPrincipalName,PasswordNeverExpires,Description -ErrorAction SilentlyContinue
    if ($existing) {
        if ($existing.DistinguishedName -notlike "*,$($script:ServiceAccountsOuDn)") {
            throw "Account '$($Definition.SamAccountName)' exists outside the target OU: $($existing.DistinguishedName)"
        }

        $changes = @{}
        $expectedUpn = Get-ServiceAccountUpn -SamAccountName $Definition.SamAccountName
        if ($expectedUpn -and $existing.UserPrincipalName -ne $expectedUpn) { $changes.UserPrincipalName = $expectedUpn }
        if ($existing.Description -ne $Definition.Description) { $changes.Description = $Definition.Description }
        if ($PasswordNeverExpires -and -not $existing.PasswordNeverExpires) { $changes.PasswordNeverExpires = $true }
        if ($changes.Count -gt 0 -and -not $ValidateOnly) { Set-ADUser -Identity $existing @changes }

        return [pscustomobject]@{
            SamAccountName = $Definition.SamAccountName
            Action = if ($changes.Count -eq 0) { 'Compliant' } elseif ($ValidateOnly) { 'Drift' } else { 'Updated' }
            Details = if ($changes.Count -eq 0) { '' } else { ($changes.Keys -join ',') }
        }
    }

    if ($ValidateOnly) {
        return [pscustomobject]@{
            SamAccountName = $Definition.SamAccountName
            Action = 'Missing'
            Details = ''
        }
    }

    $parameters = @{
        Name                  = $Definition.Name
        DisplayName           = $Definition.DisplayName
        SamAccountName        = $Definition.SamAccountName
        Path                  = $script:ServiceAccountsOuDn
        AccountPassword       = $Password
        Enabled               = $true
        ChangePasswordAtLogon = $false
        Description           = $Definition.Description
    }

    $upn = Get-ServiceAccountUpn -SamAccountName $Definition.SamAccountName
    if ($upn) {
        $parameters['UserPrincipalName'] = $upn
    }

    if ($PasswordNeverExpires) {
        $parameters['PasswordNeverExpires'] = $true
    }

    New-ADUser @parameters

    return [pscustomobject]@{
        SamAccountName = $Definition.SamAccountName
        Action = 'Created'
        Details = ''
    }
}

Resolve-Inputs
Assert-Elevated
Ensure-Module -Name ActiveDirectory
Assert-AdTargets

$definitions = @(
    @{
        Name = 'svc.join.hq'
        DisplayName = 'svc.join.hq'
        SamAccountName = 'svc.join.hq'
        Description = 'Delegated domain join service account for workstation and server onboarding.'
    },
    @{
        Name = 'svc.backup.hq'
        DisplayName = 'svc.backup.hq'
        SamAccountName = 'svc.backup.hq'
        Description = 'Backup service account for approved backup tooling and targets.'
    },
    @{
        Name = 'svc.monitor.hq'
        DisplayName = 'svc.monitor.hq'
        SamAccountName = 'svc.monitor.hq'
        Description = 'Monitoring and inventory service account for approved observability tooling.'
    },
    @{
        Name = 'svc.deploy.hq'
        DisplayName = 'svc.deploy.hq'
        SamAccountName = 'svc.deploy.hq'
        Description = 'Deployment automation service account for scripted server and workstation configuration.'
    }
)

$summary = $definitions | ForEach-Object {
    [pscustomobject]@{
        Name = $_.Name
        SamAccountName = $_.SamAccountName
        UserPrincipalName = Get-ServiceAccountUpn -SamAccountName $_.SamAccountName
        Path = $script:ServiceAccountsOuDn
        Description = $_.Description
    }
}

$summary | Format-Table -AutoSize | Out-String | Write-Output

if ($ValidateOnly) {
    $results = foreach ($definition in $definitions) {
        Ensure-ServiceAccount -Definition $definition -Password $null
    }
    $results | Format-Table -AutoSize | Out-String | Write-Output
    Write-Output 'Validation mode only. Service accounts were not created or changed.'
    return
}

$passwords = @{}
foreach ($definition in $definitions) {
    $existing = Get-ADUser -Filter "SamAccountName -eq '$($definition.SamAccountName)'" -ErrorAction SilentlyContinue
    if (-not $existing) {
        if ($AccountPasswords -and $AccountPasswords.ContainsKey($definition.SamAccountName)) {
            $providedPassword = $AccountPasswords[$definition.SamAccountName]
            if ($providedPassword -isnot [securestring]) {
                throw "AccountPasswords entry for '$($definition.SamAccountName)' must be a SecureString."
            }
            $passwords[$definition.SamAccountName] = $providedPassword
        }
        else {
            $passwords[$definition.SamAccountName] = Read-RequiredSecret -Prompt "Password for $($definition.SamAccountName)"
        }
    }
}

$results = foreach ($definition in $definitions) {
    Ensure-ServiceAccount -Definition $definition -Password $passwords[$definition.SamAccountName]
}

$results | Format-Table -AutoSize | Out-String | Write-Output
Write-Output 'Service account creation completed successfully.'

Then delegate domain-join rights to svc.join.hq on the validated workstation and server OUs:

.\Grant-HQ-JoinDelegation.ps1
Show full join-delegation script
param(
    [string]$JoinAccountSam = 'svc.join.hq',
    [string[]]$TargetOuDns,
    [switch]$ValidateOnly
)

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

function Assert-Elevated {
    $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = New-Object Security.Principal.WindowsPrincipal($identity)
    if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
        throw 'Run this script from an elevated PowerShell session.'
    }
}

function Read-RequiredValue {
    param(
        [string]$Prompt,
        [string]$DefaultValue
    )

    $fullPrompt = $Prompt
    if ($DefaultValue) {
        $fullPrompt = "$Prompt [$DefaultValue]"
    }

    while ($true) {
        $value = Read-Host -Prompt $fullPrompt
        if (-not [string]::IsNullOrWhiteSpace($value)) {
            return $value.Trim()
        }

        if (-not [string]::IsNullOrWhiteSpace($DefaultValue)) {
            return $DefaultValue
        }
    }
}

function Read-StringList {
    param(
        [string]$Prompt,
        [string[]]$DefaultValues
    )

    while ($true) {
        $defaultValue = $DefaultValues -join ','
        $value = Read-RequiredValue -Prompt $Prompt -DefaultValue $defaultValue
        $items = @(
            $value.Split(',') |
            ForEach-Object { $_.Trim() } |
            Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
        )

        if ($items.Count -gt 0) {
            return $items
        }
    }
}

function Resolve-Inputs {
    $script:JoinAccountSam = if ([string]::IsNullOrWhiteSpace($JoinAccountSam)) {
        Read-RequiredValue -Prompt 'Join account SamAccountName' -DefaultValue 'svc.join.hq'
    }
    else {
        $JoinAccountSam.Trim()
    }

    $script:TargetOuDns = if ($TargetOuDns.Count -eq 0) {
        Read-StringList -Prompt 'Target OU distinguished names (comma-separated)' -DefaultValues @(
            'OU=Workstations,OU=GNTECH,DC=corp,DC=gntech,DC=me',
            'OU=Servers,OU=GNTECH,DC=corp,DC=gntech,DC=me'
        )
    }
    else {
        @($TargetOuDns | ForEach-Object { $_.Trim() } | Where-Object { $_ })
    }
}

function Ensure-Module {
    param([string]$Name)
    Import-Module $Name
}

function Assert-JoinAccountExists {
    param([string]$SamAccountName)

    $account = Get-ADUser -Filter "SamAccountName -eq '$SamAccountName'" -Properties SID -ErrorAction SilentlyContinue
    if (-not $account) {
        throw "The join account '$SamAccountName' was not found in Active Directory."
    }

    return $account
}

function Assert-OuExists {
    param([string]$DistinguishedName)

    $ou = Get-ADOrganizationalUnit -Identity $DistinguishedName -ErrorAction SilentlyContinue
    if (-not $ou) {
        throw "The OU '$DistinguishedName' was not found."
    }

    return $ou
}

function Grant-JoinAcl {
    param(
        [string]$TargetOuDn,
        [System.Security.Principal.SecurityIdentifier]$Sid
    )

    $computerClassGuid = [Guid]'bf967a86-0de6-11d0-a285-00aa003049e2'
    $emptyGuid = [Guid]::Empty
    $aclPath = "AD:$TargetOuDn"
    $acl = Get-Acl -Path $aclPath

    $rules = @(
        (New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
            $Sid,
            [System.DirectoryServices.ActiveDirectoryRights]::CreateChild,
            [System.Security.AccessControl.AccessControlType]::Allow,
            $computerClassGuid,
            [System.DirectoryServices.ActiveDirectorySecurityInheritance]::All
        )),
        (New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
            $Sid,
            [System.DirectoryServices.ActiveDirectoryRights]::DeleteChild,
            [System.Security.AccessControl.AccessControlType]::Allow,
            $computerClassGuid,
            [System.DirectoryServices.ActiveDirectorySecurityInheritance]::All
        )),
        (New-Object System.DirectoryServices.ActiveDirectoryAccessRule(
            $Sid,
            [System.DirectoryServices.ActiveDirectoryRights]::GenericAll,
            [System.Security.AccessControl.AccessControlType]::Allow,
            $emptyGuid,
            [System.DirectoryServices.ActiveDirectorySecurityInheritance]::Descendents,
            $computerClassGuid
        ))
    )

    $results = foreach ($rule in $rules) {
        $present = @($acl.Access | Where-Object {
            $_.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq $Sid.Value -and
            $_.AccessControlType -eq $rule.AccessControlType -and
            ($_.ActiveDirectoryRights -band $rule.ActiveDirectoryRights) -eq $rule.ActiveDirectoryRights -and
            $_.ObjectType -eq $rule.ObjectType -and
            $_.InheritanceType -eq $rule.InheritanceType -and
            $_.InheritedObjectType -eq $rule.InheritedObjectType
        }).Count -gt 0

        if (-not $present -and -not $ValidateOnly) { $acl.AddAccessRule($rule) | Out-Null }

        [pscustomobject]@{
            TargetOuDn = $TargetOuDn
            IdentitySid = $Sid.Value
            Rights = $rule.ActiveDirectoryRights
            InheritanceType = $rule.InheritanceType
            Action = if ($present) { 'Compliant' } elseif ($ValidateOnly) { 'Missing' } else { 'Granted' }
        }
    }

    if (-not $ValidateOnly -and @($results | Where-Object Action -eq 'Granted').Count -gt 0) {
        Set-Acl -Path $aclPath -AclObject $acl
    }

    return $results
}

Resolve-Inputs
Assert-Elevated
Ensure-Module -Name ActiveDirectory

$joinAccount = Assert-JoinAccountExists -SamAccountName $script:JoinAccountSam

$summary = [pscustomobject]@{
    JoinAccountSamAccountName = $joinAccount.SamAccountName
    JoinAccountDn = $joinAccount.DistinguishedName
    TargetOus = ($script:TargetOuDns -join ' | ')
    ValidateOnly = [bool]$ValidateOnly
}

$summary | Format-List | Out-String | Write-Output

foreach ($targetOuDn in $script:TargetOuDns) {
    Assert-OuExists -DistinguishedName $targetOuDn | Out-Null
    Grant-JoinAcl -TargetOuDn $targetOuDn -Sid $joinAccount.SID |
        Format-Table -AutoSize | Out-String | Write-Output
}

if ($ValidateOnly) {
    Write-Output 'Validation mode only. No delegation changes were applied.'
}
else {
    Write-Output 'Join delegation completed successfully.'
}

Permission boundary:

  • svc.join.hq has a validated effective permission: create, delete, and manage computer objects beneath the configured Workstations and Servers OUs. A real join of HQ-CL02 proved the workstation path.
  • svc.backup.hq, svc.monitor.hq, and svc.deploy.hq are provisioned identities only; this runbook does not delegate application, server, share, backup, monitoring, or deployment rights to them.
  • Do not claim those three accounts are operational until a later role-specific runbook grants least privilege and captures a real functional test.
  • -PasswordNeverExpires is optional, not the default. Prefer a managed service account when the consuming product supports it.

Validate without changing AD:

.\Create-HQ-ServiceAccounts.ps1 -ServiceUpnSuffix 'gntech.me' -ValidateOnly
.\Grant-HQ-JoinDelegation.ps1 -ValidateOnly

Remediation

If post-build validation shows DHCP authorization or group errors such as:

  • The DHCP service was unable to create or lookup the DHCP Users local group
  • The DHCP server was unable to create or lookup the DHCP Administrators local group
  • The DHCP service failed to see a directory server for authorization
  • The DHCP/BINL service ... is not authorized to start

run this recovery script locally on HQ-DC01 from an elevated PowerShell session:

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
Import-Module DhcpServer

$DcFqdn = 'HQ-DC01.corp.gntech.me'
$DcIp = '172.20.20.11'

& netsh dhcp add securitygroups | Out-Null
Restart-Service DHCPServer -Force
Start-Sleep -Seconds 15

if (-not (Get-DhcpServerInDC -ErrorAction SilentlyContinue | Where-Object DnsName -eq $DcFqdn)) {
    Add-DhcpServerInDC -DnsName $DcFqdn -IpAddress $DcIp
    Start-Sleep -Seconds 15
}

Restart-Service DHCPServer -Force

Get-DhcpServerInDC
Get-Service DHCPServer | Select-Object Name,Status,StartType

If validation shows only DFSREvent warnings but NETLOGON and SYSVOL shares already exist, treat that as convergence-in-progress rather than immediate failure. Wait 15 to 30 minutes and re-run the validation script. Escalate only if:

  • NETLOGON or SYSVOL is missing
  • DFSR is not running
  • dcdiag /test:SysVolCheck or dcdiag /test:NetLogons still fails after the wait

If the first interactive console logon on HQ-DC01 shows a black screen but the domain controller services are otherwise healthy, Explorer.exe and sihost.exe may be stuck in a bad post-logon state. In the validated lab, the following local recovery restored the shell successfully without changing the DC role state:

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

Stop-Process -Name explorer -Force -ErrorAction SilentlyContinue
Stop-Process -Name sihost -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 5
Start-Process 'C:\Windows\explorer.exe'

Get-Process explorer -ErrorAction SilentlyContinue | ForEach-Object {
    Write-Output "Explorer PID: $($_.Id)"
}

Use that only as a shell recovery step when:

  • AD DS, DNS, and DHCP are already functioning
  • Winlogon shell registry values are still default
  • the issue is limited to the interactive desktop experience

Do not treat this as a substitute for fixing a broken promotion or broken directory services state.

Rollback

  • Discard the clone and recreate it from 4002 if promotion fails before the forest is successfully created.
  • If promotion partly succeeds, demote only if the failure mode is well understood; otherwise restore from snapshot or rebuild from template.
  • Ensure any temporary password-bearing script created on H1 is deleted immediately after use.

Sources

  • Microsoft Learn: Install-WindowsFeature
  • https://learn.microsoft.com/en-us/powershell/module/servermanager/install-windowsfeature?view=windowsserver2025-ps
  • Microsoft Learn: Install-ADDSForest
  • https://learn.microsoft.com/en-us/powershell/module/addsdeployment/install-addsforest?view=windowsserver2025-ps
  • Microsoft Learn: Add-DhcpServerInDC
  • https://learn.microsoft.com/en-us/powershell/module/dhcpserver/add-dhcpserverindc?view=windowsserver2025-ps
  • Microsoft Learn: Microsoft Entra UPN population
  • https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/plan-connect-userprincipalname