Deploys a Windows Server 2022 Active Directory Domain Controller to Azure using Terraform, configured the way a production environment would be: secrets in Key Vault, a system-assigned managed identity for credential-free auth, no public IP on the domain controller, and access via Azure Bastion.
This is a hands-on portfolio lab. The headline isn't "a DC got deployed" — it's the production access and secrets patterns around it, and an honest account of which corners were deliberately cut for a single-day lab and how they'd be uncut in production.
📹 Video walkthrough — a guided tour of each file, the design decisions, and what would change in a real enterprise environment.
graph TB
subgraph Internet
Admin[Admin Workstation]
end
subgraph "Azure — rg-ad-eli (southafricanorth)"
subgraph KV["Key Vault (RBAC-enabled)"]
S1[vm-admin-password]
S2[dsrm-password]
end
subgraph VNet["vnet-ad-eli — 10.0.0.0/16"]
subgraph BSub["AzureBastionSubnet — 10.0.2.0/26"]
Bastion[Azure Bastion<br/>Standard SKU]
end
subgraph ADSub["snet-ad — 10.0.1.0/24"]
NSG[NSG: RDP from VNet + mgmt IP only]
VM[vm-ad-eli<br/>DC + DNS<br/>10.0.1.4<br/>Managed Identity]
end
end
end
Admin -->|TLS / browser RDP| Bastion
Bastion -->|RDP over VNet| VM
VM -->|Managed Identity<br/>Secrets User| KV
Admin -.->|az CLI<br/>Secrets Officer| KV
style VM fill:#0078d4,color:#fff
style Bastion fill:#107c10,color:#fff
style KV fill:#5c2d91,color:#fff
Flow: Admin connects through Bastion over TLS — the DC has no public IP. The VM authenticates to Key Vault using its own managed identity (read-only on secrets). The admin populates secrets out-of-band via the Azure CLI (read/write). Terraform state lives in a remote, encrypted Azure Storage backend.
| Resource | Name | Notes |
|---|---|---|
| Resource group | rg-ad-eli |
Everything nests here; single destroy cleans up |
| Virtual network | vnet-ad-eli |
10.0.0.0/16 |
| AD subnet | snet-ad |
10.0.1.0/24; DC at static 10.0.1.4 |
| Bastion subnet | AzureBastionSubnet |
10.0.2.0/26; name is mandatory, /26 minimum |
| Key Vault | kv-adlab-eli-01 |
RBAC authorization; holds both secrets |
| NSG | nsg-ad-eli |
RDP from mgmt IP + VirtualNetwork tag only |
| NIC | nic-ad-eli |
No public IP |
| VM | vm-ad-eli |
Windows Server 2022, Standard_DS1_v2, system-assigned identity |
| Bastion | bastion-ad-eli |
Standard SKU, own public IP |
| Extension | install-ad-ds |
Installs AD DS, promotes new forest corp.eli.com |
Managed identity over passwords-in-code. The VM gets a system-assigned managed identity that's granted Key Vault Secrets User. The password variables most tutorials keep in variables.tf were removed entirely — a variable is just one more place a secret can leak.
RBAC over access policies on Key Vault. rbac_authorization_enabled = true puts vault permissions on the same auditable control plane as the rest of Azure. Two scoped, least-privilege assignments: the admin gets Secrets Officer (read/write), the VM identity gets Secrets User (read-only).
Data-plane vs. control-plane roles. Secrets Officer/Secrets User act on the secrets inside the vault — distinct from Contributor, which manages the vault resource. Managing a vault and reading its secrets are separate grants.
No public IP on the domain controller. The DC is internet-unreachable. Access is via Bastion over TLS, so the only public-facing surface is a managed, patched jump host — never an open 3389 on a DC.
Remote, encrypted Terraform state. State holds secrets in cleartext, so it lives in an access-controlled Azure Storage backend, never on disk. Each lab uses a distinct state key in shared state storage.
Staged apply for secret bootstrapping. Because the secret data sources read at plan time, the build is staged: (1) targeted apply creates the vault + admin access, (2) secrets set via CLI, (3) full apply reads them and builds everything. Consumers can't read secrets that don't yet exist — provisioning order matters.
Prerequisites: Azure CLI (az login), Terraform 1.3+, an Azure subscription, and a remote-state storage account.
# Stage 1 — vault + your access only
terraform init
terraform apply -target="azurerm_key_vault.main" -target="azurerm_role_assignment.kv_admin"
# Stage 2 — set secrets (wait ~60s for RBAC propagation first)
az keyvault secret set --vault-name "kv-adlab-eli-01" --name "vm-admin-password" --value '<strong-password>'
az keyvault secret set --vault-name "kv-adlab-eli-01" --name "dsrm-password" --value '<strong-password>'
# Stage 3 — full build (~25-30 min; Bastion alone is 10-15)
terraform applyConnect: Azure Portal → vm-ad-eli → Connect → Bastion → CORP\adadmin + the admin password.
Teardown (same day): terraform destroy.
Clean apply — 11 resources, extension completed without error:
Domain controller has no public IP — access is Bastion-only:
Get-ADDomain confirms a healthy forest corp.eli.com (NetBIOS CORP):
VM system-assigned managed identity:
Key Vault RBAC — admin as Secrets Officer, VM identity as Secrets User, both scoped to the vault:
Provider deprecation caught and remediated, then clean validate:
| Issue | Cause | Resolution / lesson |
|---|---|---|
Too many command line arguments on -target |
PowerShell parses the flag differently than bash | Quote each address: -target="azurerm_key_vault.main" |
403 Forbidden setting secrets right after apply |
Azure RBAC takes 30s–few min to propagate | Wait ~60s and retry — propagation, not a config error |
First full apply would fail reading secrets |
Data sources read at plan time; secrets didn't exist yet | Stage the apply — vault first, set secrets, then full apply |
enable_rbac_authorization deprecation warning |
azurerm 4.x renamed the property | Updated to rbac_authorization_enabled ahead of the 5.0 removal |
| Bastion won't deploy into the subnet | Subnet must be named exactly AzureBastionSubnet, /26 min |
Use the literal mandated name and size |
Extension may report Failed (didn't here) |
AD promotion reboots the VM mid-extension | Benign — verify with Get-ADDomain, not extension status |
| Key Vault name collision on rebuild | Soft-delete leaves a ghost after destroy | purge_soft_delete_on_destroy = true, or az keyvault purge |
curl -s hangs at a Uri: prompt |
curl is an alias for Invoke-WebRequest in PowerShell |
Use Invoke-RestMethod or curl.exe |
These were left out on purpose for a single-day free-tier lab. Knowing the gap and how to close it is the point.
- Key Vault networking — uses its public endpoint gated by RBAC for lab access. Production: private endpoint + network ACLs, no public reachability.
- Purge protection — disabled to allow same-day rebuilds. Production:
purge_protection_enabled = true. - Secrets in state — even sourced from Key Vault, the passwords materialize in Terraform state and the extension settings (Terraform must use the value). Production: runtime secret retrieval via the VM's managed identity inside a bootstrap script, so the value never enters state. The remote encrypted backend mitigates the at-rest exposure in the meantime.
- Observability — no diagnostics here. Production: VM and Key Vault diagnostic settings shipped to a Log Analytics workspace for audit and alerting.
- Resilience — a single forest-root DC is a lab. Production: multiple DCs across availability zones with a proper AD site topology.
.
├── main.tf # All resources + AD DS extension
├── variables.tf # Inputs (no secrets)
├── outputs.tf # Names, IDs, access info (no secrets)
├── terraform.tfvars # Real values (git-ignored)
├── terraform.tfvars.example # Template (committed)
├── .gitignore # Excludes tfvars + state
└── screenshots/ # Deployment evidence





