Building My Home Lab – Part 6: Adding Test Users & Groups to the Active Directory Domain

Building My Home Lab – Part 6: Adding Test Users & Groups to the Active Directory Domain

Ed Bradley’s Web: Weaving Networks, Security, Cloud, and Code.

Introduction

In this post, I’ll be focusing on populating the new Active Directory domain with test users and groups.

The goal of this exercise is to simulate a typical small-to-medium business environment — one with multiple departments, diverse user roles, and realistic operational structure.

Using PowerShell, I’ll automate the process of:

  • Creating a well-structured OU hierarchy within the new lab domain (edbradleyweb.local).
  • Populating it with realistic test users and department-specific security groups.
  • Supporting the management of BYOD (Bring Your Own Device) scenarios and secure RADIUS-based wireless access.
  • Enabling a full teardown process to cleanly remove all test objects while preserving the core AD structure.

To wrap up, I’ll also join the first workstation/desktop computer to the domain to validate the environment setup.

Overview

I’m creating a root OU to hold all domain objects for the simulated business. This provides four big benefits:

  • Easy cleanup/reset: Everything for the lab lives under one branch. When I’m done (or want a fresh start), I can delete the entire subtree and the domain returns to its “out-of-the-box” state—default Users/Computers containers untouched.
  • Isolation: I can Block Inheritance at the root OU (e.g., OU=EDW,DC=edbradleyweb,DC=local) to stop domain/root GPOs from flowing into the lab, and keep all lab GPO links scoped under EDW—avoiding accidental domain-wide impact.
  • Simple delegation: In a real environment, I could delegate admin rights for a specific division or business unit (organized under its own root OU) without affecting other parts of the AD structure.
  • Scripting sanity: All DN paths share a single, predictable anchor (e.g., OU=EDW,...), which makes PowerShell scripts, exports, and documentation cleaner and easier to maintain.

Automation Scripts

With the help of ChatGPT, I "vibe coded" two PowerShell scripts:

  • Create-EDW-Lab.ps1 (Create)
  • Remove-EDW-Lab.ps1 (Teardown)

Create the business environment OU (working script):

<#
.SYNOPSIS
  Create EDW lab scaffold (v3.3, PowerShell 5.1 compatible, idempotent/re-run-safe).
  - OUs: EDW root, per-dept, Users/Computers/Laptops children
  - Groups: per-dept (GG-<Dept>-Users/Computers), Wi-Fi groups at EDW\Groups
  - Users: 100 random users evenly distributed, random strong passwords, CSV manifest
  - UPN format: First.Last@<UPNSuffix> with AD-aware uniqueness (First.Last2, First.Last3, ...)
  - sAMAccountName: Firstname.Lastname (lowercase) with AD-aware uniqueness and <=20 char enforcement
  - GPOs: create and link baseline GPOs (Enforced Yes/No)

.NOTES
  Run elevated as a member of Domain Admins + Group Policy Creator Owners.
  Requires modules: ActiveDirectory, GroupPolicy.
#>

[CmdletBinding(SupportsShouldProcess=$true)]
param(
  [int]$UserCount = 100,
  [string]$RootOuName = "EDW",
  [string]$GpoPrefix = "EDW - ",
  [string]$UPNSuffix = "edbradleyweb.local"   # Change if you add more UPN suffixes
)

function Ensure-Module { param([string]$Name) if (-not (Get-Module -ListAvailable -Name $Name)) { throw "Required module '$Name' not available." } Import-Module $Name -ErrorAction Stop }
function Get-DomainDN { (Get-ADDomain -ErrorAction Stop).DistinguishedName }
function Require-AdminGroups {
  $who=[System.Security.Principal.WindowsIdentity]::GetCurrent().Name
  $groups=(whoami /groups) -join "`n"
  $need=@('Domain Admins','Group Policy Creator Owners')
  $missing=$need | Where-Object { $groups -notmatch [regex]::Escape($_) }
  if($missing){ throw ("Preflight failed for {0}. Missing: {1}. Log off/on or run as 'edbradleyweb\\Administrator'." -f $who, ($missing -join ', ')) }
}

function New-SafeOU([string]$Name,[string]$ParentDN,[bool]$Protect=$true){
  $exists=Get-ADOrganizationalUnit -LDAPFilter ("(ou={0})" -f $Name) -SearchBase $ParentDN -SearchScope OneLevel -ErrorAction SilentlyContinue
  if(-not $exists){
    Write-Host ("Creating OU: OU={0},{1}" -f $Name,$ParentDN) -ForegroundColor Cyan
    New-ADOrganizationalUnit -Name $Name -Path $ParentDN -ProtectedFromAccidentalDeletion:$Protect -ErrorAction Stop | Out-Null
  } else { Write-Host ("OU exists: OU={0},{1}" -f $Name,$ParentDN) }
  "OU={0},{1}" -f $Name,$ParentDN
}
function New-SafeGroup([string]$Name,[string]$Path,[ValidateSet('Global','DomainLocal','Universal')][string]$Scope='Global'){
  $exists=Get-ADGroup -LDAPFilter ("(cn={0})" -f $Name) -SearchBase $Path -SearchScope OneLevel -ErrorAction SilentlyContinue
  if(-not $exists){
    Write-Host ("Creating Group: CN={0} in {1}" -f $Name,$Path) -ForegroundColor Cyan
    New-ADGroup -Name $Name -GroupScope $Scope -Path $Path -GroupCategory Security -ErrorAction Stop | Out-Null
  } else { Write-Host ("Group exists: {0}" -f $Name) }
}
function Ensure-GPO([string]$Name){
  $g=Get-GPO -All -ErrorAction Stop | Where-Object { $_.DisplayName -eq $Name }
  if(-not $g){ Write-Host ("Creating GPO: {0}" -f $Name) -ForegroundColor Cyan; $g=New-GPO -Name $Name -ErrorAction Stop }
  else{ Write-Host ("GPO exists: {0}" -f $Name) }
  $g
}
function Ensure-GPLink([string]$GpoName,[string]$TargetDn,[ValidateSet('Yes','No')][string]$Enforced='No'){
  $ou=Get-ADOrganizationalUnit -Identity $TargetDn -ErrorAction SilentlyContinue
  if(-not $ou){ throw ("GPLink target not found: {0}" -f $TargetDn) }
  $inherit=Get-GPInheritance -Target $TargetDn -ErrorAction Stop
  $existing=$inherit.GpoLinks | Where-Object { $_.DisplayName -eq $GpoName }
  if(-not $existing){
    Write-Host ("Linking GPO '{0}' to {1} (Enforced={2})" -f $GpoName,$TargetDn,$Enforced) -ForegroundColor Cyan
    New-GPLink -Name $GpoName -Target $TargetDn -Enforced $Enforced -ErrorAction Stop | Out-Null
  }
  elseif($existing.Enforced -ne $Enforced){
    Write-Host ("Updating link enforcement on '{0}' at {1} -> {2}" -f $GpoName,$TargetDn,$Enforced) -ForegroundColor Cyan
    Set-GPLink -Name $GpoName -Target $TargetDn -Enforced $Enforced -ErrorAction Stop
  }
  else{ Write-Host ("GPO link already present: '{0}' @ {1} (Enforced={2})" -f $GpoName,$TargetDn,$Enforced) }
}

# Random names & password helpers
$FirstNames='Liam','Olivia','Noah','Emma','Oliver','Ava','Elijah','Sophia','James','Isabella','William','Mia','Benjamin','Charlotte','Lucas','Amelia','Henry','Harper','Alexander','Evelyn','Michael','Abigail','Daniel','Emily','Logan','Elizabeth','Jackson','Avery','Sebastian','Sofia','Jack','Scarlett','Owen','Chloe','Theodore','Ella','Aiden','Grace','Samuel','Victoria','Joseph','Riley','John','Zoey','David','Nora','Wyatt','Lily','Matthew','Hannah'
$LastNames='Smith','Johnson','Williams','Brown','Jones','Garcia','Miller','Davis','Rodriguez','Martinez','Hernandez','Lopez','Gonzalez','Wilson','Anderson','Thomas','Taylor','Moore','Jackson','Martin','Lee','Perez','Thompson','White','Harris','Sanchez','Clark','Ramirez','Lewis','Robinson','Walker','Young','Allen','King','Wright','Scott','Torres','Nguyen','Hill','Flores','Green','Adams','Nelson','Baker','Hall','Rivera','Campbell','Mitchell','Carter','Roberts'

function New-RandomPassword([int]$Length=14){
  $sets=@(
    { [char[]]'ABCDEFGHIJKLMNOPQRSTUVWXYZ' | Get-Random },
    { [char[]]'abcdefghijklmnopqrstuvwxyz' | Get-Random },
    { [char[]]'0123456789' | Get-Random },
    { [char[]]'!@#$%^&*_-+=?' | Get-Random }
  )
  $pwd=@(); foreach($s in $sets){ $pwd += (& $s) }
  $all=([char[]]'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*_-+=?')
  for($i=$pwd.Count; $i -lt $Length; $i++){ $pwd += ($all | Get-Random) }
  -join ($pwd | Get-Random -Count $pwd.Count)
}

function Sanitize-Token([string]$s){
  if([string]::IsNullOrWhiteSpace($s)){ return "" }
  return ($s -replace "[^A-Za-z0-9]", "")
}

# sAMAccountName base: Firstname.Lastname (lowercase), AD-aware uniqueness with numeric suffix
# sAMAccountName max length is 20 chars. We trim the base to leave room for a numeric suffix when needed.
function New-UniqueSamAD([string]$Given,[string]$Surname,[object]$Taken){
  $g = Sanitize-Token $Given
  $sn = Sanitize-Token $Surname
  if([string]::IsNullOrWhiteSpace($g)){ $g = "User" }
  if([string]::IsNullOrWhiteSpace($sn)){ $sn = "Person" }

  $base = ("{0}.{1}" -f $g.ToLower(), $sn.ToLower())

  function Trim-ForSuffix([string]$b, [int]$suffixLen){
    $maxBase = 20 - $suffixLen
    if($maxBase -lt 1){ $maxBase = 1 }
    if($b.Length -gt $maxBase){ return $b.Substring(0, $maxBase) }
    return $b
  }

  $sam = Trim-ForSuffix -b $base -suffixLen 0
  $i = 1
  while($Taken.Contains($sam) -or (Get-ADUser -LDAPFilter ("(sAMAccountName={0})" -f $sam) -ErrorAction SilentlyContinue)){
    $sfx = $i.ToString()
    $sam = (Trim-ForSuffix -b $base -suffixLen $sfx.Length) + $sfx
    $i++
  }
  [void]$Taken.Add($sam)
  return $sam
}

# UPN base: First.Last (preserve capitalization), AD-aware uniqueness with numeric suffix
function New-UniqueUpnAD([string]$Given,[string]$Surname,[string]$Suffix){
  $g=Sanitize-Token $Given; $sn=Sanitize-Token $Surname
  if($g -eq ""){ $g="User" }
  if($sn -eq ""){ $sn="Person" }
  $base=("{0}.{1}" -f $g,$sn)
  $candidate=("{0}@{1}" -f $base,$Suffix)
  $i=2
  while(Get-ADUser -LDAPFilter ("(userPrincipalName={0})" -f $candidate) -ErrorAction SilentlyContinue){
    $candidate=("{0}{1}@{2}" -f $base,$i,$Suffix)
    $i++
  }
  $candidate
}

# ==== Main ====
try{ Ensure-Module ActiveDirectory; Ensure-Module GroupPolicy; Require-AdminGroups } catch { Write-Error $_; exit 1 }

$DomainDN=Get-DomainDN
$RootDN=("OU={0},{1}" -f $RootOuName,$DomainDN)

$Departments='Accounting','Sales','Human Resources','Information Technology','Research & Development','Shipping & Receiving','Marketing','Customer Service'

# OUs
$null=New-SafeOU -Name $RootOuName -ParentDN $DomainDN -Protect:$true
$GroupsDN=New-SafeOU -Name 'Groups' -ParentDN $RootDN -Protect:$true

foreach($dept in $Departments){
  $deptDn=New-SafeOU -Name $dept -ParentDN $RootDN -Protect:$true
  $usersDn=New-SafeOU -Name 'Users' -ParentDN $deptDn -Protect:$false
  $computersDn=New-SafeOU -Name 'Computers' -ParentDN $deptDn -Protect:$false
  $laptopsDn=New-SafeOU -Name 'Laptops' -ParentDN $deptDn -Protect:$false

  New-SafeGroup -Name ("GG-{0}-Users" -f $dept) -Path $deptDn -Scope Global
  New-SafeGroup -Name ("GG-{0}-Computers" -f $dept) -Path $deptDn -Scope Global
}

# Wi-Fi / NPS groups
$WifiGroups='GG-WiFi-Employees','GG-WiFi-Contractors','GG-WiFi-Devices'
foreach($g in $WifiGroups){ New-SafeGroup -Name $g -Path $GroupsDN -Scope Global }

# GPO Scaffold
$GpoNames=@(
  ("{0}Windows Baseline - Computers" -f $GpoPrefix),
  ("{0}Windows Baseline - Users" -f $GpoPrefix),
  ("{0}LAPS - Computers" -f $GpoPrefix)
)
$Gpos=@{}; foreach($n in $GpoNames){ $Gpos[$n]=Ensure-GPO $n }

foreach($dept in $Departments){
  Ensure-GPLink -GpoName $GpoNames[0] -TargetDn ("OU=Computers,OU={0},{1}" -f $dept,$RootDN) -Enforced No
  Ensure-GPLink -GpoName $GpoNames[0] -TargetDn ("OU=Laptops,OU={0},{1}"   -f $dept,$RootDN) -Enforced No
  Ensure-GPLink -GpoName $GpoNames[1] -TargetDn ("OU=Users,OU={0},{1}"      -f $dept,$RootDN) -Enforced No
}

# Users: even distribution
$basePer=[math]::Floor($UserCount / $Departments.Count)
$rem=$UserCount % $Departments.Count
$quota=@{}
for($i=0; $i -lt $Departments.Count; $i++){ $q=$basePer; if($i -lt $rem){ $q = $q + 1 }; $quota[$Departments[$i]] = $q }

$created=0
$TakenSam=[System.Collections.Generic.HashSet[string]]::new()
$rows=@()
Write-Host ("Creating {0} users across {1} departments..." -f $UserCount,$Departments.Count) -ForegroundColor Cyan

foreach($dept in $Departments){
  $targetOu=("OU=Users,OU={0},{1}" -f $dept,$RootDN)
  for($n=0;$n -lt $quota[$dept];$n++){
    $given=$FirstNames | Get-Random; $sur=$LastNames | Get-Random
    $display=("{0} {1}" -f $given,$sur)
    $sam=New-UniqueSamAD -Given $given -Surname $sur -Taken $TakenSam
    $upn=New-UniqueUpnAD -Given $given -Surname $sur -Suffix $UPNSuffix
    $pwd=New-RandomPassword
    try{
      Write-Host (" -> {0} -> {1}" -f $display,$targetOu)
      New-ADUser -Name $display -GivenName $given -Surname $sur -SamAccountName $sam -UserPrincipalName $upn -DisplayName $display `
        -EmailAddress $upn -Department $dept -Enabled $true -Path $targetOu `
        -AccountPassword (ConvertTo-SecureString $pwd -AsPlainText -Force) -ChangePasswordAtLogon $true -ErrorAction Stop
      Add-ADGroupMember -Identity ("GG-{0}-Users" -f $dept) -Members $sam -ErrorAction Stop
      $rows += [pscustomobject]@{GivenName=$given;Surname=$sur;DisplayName=$display;SamAccount=$sam;UPN=$upn;Department=$dept;OU=$targetOu;Password=$pwd}
      $created++
    } catch { Write-Warning ("Failed to create user {0} in {1}: {2}" -f $display,$dept,$_.Exception.Message) }
  }
}

# Output / artifacts (Reports folder next to script)
$scriptDir = Split-Path -Path $PSCommandPath -Parent
$reportDir = Join-Path -Path $scriptDir -ChildPath "Reports"
if(-not (Test-Path $reportDir)){ New-Item -ItemType Directory -Path $reportDir | Out-Null }
$csvPath = Join-Path $reportDir "EDW_Lab_Users.csv"
$rows | Export-Csv -NoTypeInformation -Path $csvPath -Encoding UTF8

Write-Host ("Created/verified {0} users. CSV: {1}" -f $created,$csvPath) -ForegroundColor Green
Write-Host "All done." -ForegroundColor Green

Tear down the OU (working script):


<#
.SYNOPSIS
  Remove EDW lab scaffold (v3). Exports inventory/backup to the local Reports folder (same pattern as Create script),
  removes GPOs by prefix, and deletes the EDW OU subtree.

.NOTES
  Run elevated. Requires: ActiveDirectory, GroupPolicy modules.
#>

[CmdletBinding(SupportsShouldProcess=$true, ConfirmImpact='High')]
param(
  [string]$RootOuName = "EDW",
  [string]$GpoPrefix  = "EDW - ",
  [switch]$Force
)

function Ensure-Module { param([string]$Name) if (-not (Get-Module -ListAvailable -Name $Name)) { throw "Required module '$Name' not available." } Import-Module $Name -ErrorAction Stop }
function Get-DomainDN { (Get-ADDomain -ErrorAction Stop).DistinguishedName }

function Get-ReportsPath {
  # Mirror the Create script: drop artifacts under a local "Reports" folder next to the script
  $base = Split-Path -Path $PSCommandPath -Parent
  $rep  = Join-Path $base 'Reports'
  if (-not (Test-Path $rep)) { New-Item -ItemType Directory -Path $rep | Out-Null }
  return $rep
}

function Export-Inventory([string]$RootDn,[string]$ReportsPath){
  $stamp = Get-Date -Format "yyyyMMdd-HHmmss"
  $outDir = Join-Path $ReportsPath ("EDW_Teardown_{0}" -f $stamp)
  New-Item -ItemType Directory -Path $outDir | Out-Null
  Write-Host ("Exporting inventory to {0} ..." -f $outDir) -ForegroundColor Cyan

  Get-ADUser -Filter * -SearchBase $RootDn -SearchScope Subtree -Properties * |
    Select-Object Name,SamAccountName,DistinguishedName,Enabled,Department,mail |
    Export-Csv -NoTypeInformation -Path (Join-Path $outDir "users.csv")

  Get-ADComputer -Filter * -SearchBase $RootDn -SearchScope Subtree -Properties * |
    Select-Object Name,DistinguishedName,Enabled,OperatingSystem |
    Export-Csv -NoTypeInformation -Path (Join-Path $outDir "computers.csv")

  Get-ADGroup -Filter * -SearchBase $RootDn -SearchScope Subtree -Properties * |
    Select-Object Name,DistinguishedName,GroupScope,GroupCategory |
    Export-Csv -NoTypeInformation -Path (Join-Path $outDir "groups.csv")

  Get-ADOrganizationalUnit -Filter * -SearchBase $RootDn -SearchScope Subtree -Properties * |
    Select-Object Name,DistinguishedName,ProtectedFromAccidentalDeletion |
    Export-Csv -NoTypeInformation -Path (Join-Path $outDir "ous.csv")

  $gpos = Get-GPO -All | Where-Object { $_.DisplayName -like "$GpoPrefix*" }
  $gpos | Select-Object DisplayName,Id |
    Export-Csv -NoTypeInformation -Path (Join-Path $outDir "gpos.csv")

  Write-Host "Inventory export complete." -ForegroundColor Green
  return $outDir
}

function Clear-OUProtectionRecursively([string]$RootDn){
  Write-Host "Clearing 'Protect from accidental deletion' on OU subtree..." -ForegroundColor Cyan
  $ous = Get-ADOrganizationalUnit -Filter * -SearchBase $RootDn -SearchScope Subtree -Properties ProtectedFromAccidentalDeletion
  foreach ($ou in $ous) {
    if ($ou.ProtectedFromAccidentalDeletion) {
      try {
        Set-ADOrganizationalUnit -Identity $ou.DistinguishedName -ProtectedFromAccidentalDeletion:$false -ErrorAction Stop
      } catch {
        Write-Warning ("Failed to clear protection on: {0} -> {1}" -f $ou.DistinguishedName, $_.Exception.Message)
      }
    }
  }
  try { Set-ADOrganizationalUnit -Identity $RootDn -ProtectedFromAccidentalDeletion:$false -ErrorAction Stop } catch {}
}

function Remove-EDWGPOs([string]$Prefix,[switch]$ForceLocal){
  $gpos = Get-GPO -All | Where-Object { $_.DisplayName -like "$Prefix*" }
  if (-not $gpos) { Write-Host ("No GPOs found with prefix '{0}'." -f $Prefix) -ForegroundColor Yellow; return }
  Write-Host ("Found {0} GPO(s) with prefix '{1}'" -f $gpos.Count,$Prefix) -ForegroundColor Cyan
  foreach ($gpo in $gpos) {
    if ($PSCmdlet.ShouldProcess(("GPO '{0}'" -f $gpo.DisplayName),"Remove-GPO")) {
      try {
        Remove-GPO -Guid $gpo.Id -Confirm:(!$ForceLocal) -ErrorAction Stop
        Write-Host ("Removed GPO: {0}" -f $gpo.DisplayName) -ForegroundColor Green
      } catch { Write-Warning ("Failed to remove GPO '{0}': {1}" -f $gpo.DisplayName, $_.Exception.Message) }
    }
  }
}

try { Ensure-Module ActiveDirectory; Ensure-Module GroupPolicy } catch { Write-Error $_; exit 1 }

$domainDN = Get-DomainDN
$rootDN   = "OU=$RootOuName,$domainDN"
$rootOu = Get-ADOrganizationalUnit -LDAPFilter "(ou=$RootOuName)" -SearchBase $domainDN -SearchScope OneLevel -ErrorAction SilentlyContinue
if (-not $rootOu) { Write-Error ("Root OU '{0}' not found under {1}. Nothing to remove." -f $RootOuName, $domainDN); exit 1 }

if (-not $Force) {
  Write-Warning ("You are about to DELETE everything under: {0}" -f $rootDN)
  $confirm = Read-Host "Type YES to continue"
  if ($confirm -ne "YES") { Write-Host "Aborted." -ForegroundColor Yellow; exit 0 }
}

$reports = Get-ReportsPath
$backupDir = Export-Inventory -RootDn $rootDN -ReportsPath $reports

Remove-EDWGPOs -Prefix $GpoPrefix -ForceLocal:$Force
Clear-OUProtectionRecursively -RootDn $rootDN

if ($PSCmdlet.ShouldProcess($rootDN,"Remove-ADOrganizationalUnit -Recursive")) {
  try {
    Remove-ADOrganizationalUnit -Identity $rootDN -Recursive -Confirm:(!$Force) -ErrorAction Stop
    Write-Host ("Deleted OU subtree: {0}" -f $rootDN) -ForegroundColor Green
  } catch { Write-Error ("Failed to delete OU subtree: {0}" -f $_.Exception.Message) }
}

Write-Host ("Teardown complete. Inventory saved to: {0}" -f $backupDir) -ForegroundColor Green

Backup first. Before running the creation script, I took a full standalone backup of the server to an external USB drive. Later in the lab build, I’ll deploy Veeam Community Edition as the permanent, network-wide backup solution:

After squashing a few bugs, the working scripts (above) successfully created and tore down the OU hierarchy.

Create Script execution (successful):

The resulting OU structure was generated in Active Directory:

And the Group Policies were generated, as expected:

The script creates a handy .csv file listing of all the generated user accounts and first-time passwords:

Running the Remove (Teardown) script:

The OU structure and Group Policy objects were successfully removed. A collection of reports are also generated, listing all the deleted objects:


Manually Joining a Workstation to the Domain

With the AD OU automation in place, I added an initial physical workstation to the domain: a refurbished HP EliteDesk 800 G4 Mini (Core i5-8600, 8 GB RAM, 256 GB M.2 SSD) running Windows 11 Pro.

This box will live on VLAN 30 and serve as my hands-on test client for lab scenarios.

I followed the normal steps to join it to the domain:

1.) In Windows, search “Domain” → open Access work or school → click Connect:

2.) This is a traditional local, on-premises Domain, so I clicked "Join this device to a local Active Directory domain":

3.) I entered the domain name(edbradleyweb.local) and clicked Next:

4.) I was prompted for my user name and password. I had already created the account earlier in the week. I entered the credentials, as shown below:

5.) I left the default "Standard User" account setting and clicked Next. Best practice: end users should not have local admin rights; this reduces risk of misuse or malware escalation:

6.) After the Join completed, I was prompted to reboot. After the reboot, the computer's device specifications show that it is part of the edbradleyweb.local domain:


🧩 Wrap Up

This post documents how I populated a fresh AD domain with a realistic lab organization using two PowerShell scripts: one to build a structured EDW root OU (per-department Users/Computers/Laptops), create per-dept security groups, and generate 100 realistic users with strong passwords and a CSV manifest; and one to tear down the entire lab safely (exports inventory, removes GPOs, clears protections, deletes the subtree). It explains why a root OU is invaluable—fast cleanup, policy isolation via Block Inheritance, clean delegation, and predictable DN paths for scripting. I captured a standalone backup first (with Veeam Community Edition planned as the long-term solution), then validated the setup by joining a Windows 11 HP EliteDesk on VLAN 30 to the domain. The result is a repeatable, auditable way to stand up and reset a small-to-medium business simulation—ready for BYOD and secure RADIUS/Wi-Fi access and for layering in day-1 hardening and GPO baselines.

In upcoming posts, I’ll keep building out the lab—standing up secure wireless access, adding virtualization hosts, and deploying guest VMs across the network.

Stay tuned!