Build a practical Azure security lab with Terraform. The lab creates a small hub-spoke environment where an untrusted test client reaches a protected workload only through controlled network paths. Azure Firewall Basic sits in the hub, NSGs and route tables enforce segmentation, Key Vault stores generated secrets, Log Analytics collects diagnostics, and Azure Policy audits posture.
The design follows the same style as the Azure Landing Zone Lab: clear feature toggles, reusable modules, diagrams, wiki pages, CI checks, and hands-on validation steps.
Cost note: Azure Firewall is the main cost driver. In West Europe, Azure Firewall Basic is roughly 0.40 USD/hour plus data processing, so a full month can be about 292 USD before other resources. Deploy it for practice, validate the scenario, then destroy the lab.
Use this repository like a compact security-lab encyclopedia: the root README is
the landing article, and the wiki/ folder is the linked body of knowledge.
| Topic | Start here | Deep dive |
|---|---|---|
| Architecture | Architecture | Architecture overview, addressing and routing, security controls |
| Technology stack | What Gets Deployed | Technology stack, module map, variables |
| Deployment lifecycle | Quick Start | Deploy the lab, apply-test-destroy runbook, final validation checklist |
| Network segmentation | Lab Scenarios | Firewall east-west routing, private access with Bastion |
| Secrets and identity | Security Features | Key Vault secrets, Entra test users |
| Detection and evidence | Testing the Lab | Security test matrix, Log Analytics detection, KQL examples, validation report |
| Governance and cost | Cost Estimation | Policy posture, cost control, GitHub Actions |
| Terminology | References | Glossary, Microsoft Learn and product references |
The main toggles live in terraform.tfvars.example and environments/lab.tfvars.
deploy_firewall = true
firewall_sku_tier = "Basic"
force_spoke_egress_to_firewall = false
enable_test_client_public_ip = true
deploy_bastion = false
deploy_entra_test_users = false
deploy_conditional_access = false
deploy_sentinel = false
deploy_keyvault = true
deploy_log_analytics = true
deploy_policy = true
deploy_budget = trueThe default keeps the lab easy to access with a restricted public RDP rule on the test client. Set admin_source_ip_cidr to your own public IP with /32.
- Overview
- Wiki-Style Knowledge Map
- Master Control Panel
- Architecture
- Lab Scenarios
- What Gets Deployed
- Quick Start
- Validated Environment Run
- GitHub Actions
- Configuration Options
- Testing the Lab
- Security Features
- Cost Estimation
- Troubleshooting
- Project Structure
This Terraform project creates a focused security lab, not a full enterprise landing zone. It is built to make security controls visible and testable without deploying a large stack.
- Hub VNet: central connectivity and Azure Firewall subnet.
- Test spoke: untrusted Windows test client with optional restricted public RDP.
- Protected spoke: Windows IIS web VM without public IP.
- Azure Firewall Basic: east-west routing, web allow rules, explicit admin-protocol deny example.
- NSGs: subnet-level allow and deny rules.
- Route tables: force test-to-protected and protected-to-test traffic through the firewall.
- Key Vault: generated Windows admin password and lab details.
- Log Analytics: firewall and Key Vault diagnostics.
- Azure Policy: audit-mode posture controls.
- Budget: subscription budget alerts through Owner contact role.
| Component | Toggle | Default | Notes |
|---|---|---|---|
| Azure Bastion | deploy_bastion |
false |
Easier private access, but adds hourly cost. |
| Entra test users/groups | deploy_entra_test_users |
false |
Requires directory permissions and entra_domain_name. |
| Conditional Access | deploy_conditional_access |
false |
Report-only MFA policy, requires Entra ID P1/P2. |
| Microsoft Sentinel | deploy_sentinel |
false |
Useful for SOC labs, but ingestion can increase cost. |
| Spoke default egress via firewall | force_spoke_egress_to_firewall |
false |
Keep false when using public RDP to avoid asymmetric routing. |
Internet
|
| restricted RDP from admin_source_ip_cidr
v
Test Spoke 10.23.0.0/16
|
| UDR: protected spoke via firewall
v
Hub VNet 10.20.0.0/16
|
| Azure Firewall Basic
v
Protected Spoke 10.22.0.0/16
|
| private web VM, no public IP
v
Protected workload
| Zone | CIDR | Purpose |
|---|---|---|
| Hub | 10.20.0.0/16 |
Firewall and optional Bastion |
| Azure Firewall subnet | 10.20.1.0/24 |
Required reserved subnet name |
| Azure Firewall management subnet | 10.20.3.0/24 |
Required by Azure Firewall Basic |
| Azure Bastion subnet | 10.20.2.0/26 |
Created only when Bastion is enabled |
| Protected spoke | 10.22.0.0/16 |
Private workload |
| Protected web subnet | 10.22.1.0/24 |
Windows IIS web VM |
| Test spoke | 10.23.0.0/16 |
Untrusted client |
| Test client subnet | 10.23.1.0/24 |
Windows test VM |
The wiki now includes a Microsoft Learn-style path with objectives, diagrams, exact validation commands, expected results, troubleshooting, cleanup, and product reference links. Start with the wiki index, then use the final validation checklist before sharing or pushing the lab. For a pre-commit proof pass, use the final-check runbook and the security test matrix.
Objective: prove that private spoke-to-spoke traffic crosses Azure Firewall.
Expected result:
curl http://PROTECTED_PRIVATE_IPsucceeds from the test client.- RDP or WinRM from test to protected fails.
- Firewall logs show allowed web flows and denied admin attempts.
Objective: understand how NSGs and UDRs work together.
Controls:
- Protected VM has no public IP.
- Protected NSG only allows HTTP/S from the test spoke.
- Route tables send private spoke traffic through the firewall when
deploy_firewall = true.
Objective: store generated lab access material safely.
Secrets include:
windows-admin-passwordtest-client-private-ipprotected-web-private-ipprotected-web-test-url- firewall IPs when deployed
- Entra test user passwords when enabled
Objective: create disposable users and groups for identity/security practice.
Users:
sec-adminsec-analystlab-userbreak-glass
Groups:
security-adminssecurity-analystslab-usersbreak-glass
This scenario is disabled by default because it needs directory permissions and a verified domain.
Objective: practice audit-mode governance without breaking the lab.
Policies audit:
- required
environmenttag - allowed locations
- public IP creation
- storage accounts without HTTPS-only traffic
Objective: inspect security telemetry in Log Analytics.
Included starter query:
AzureDiagnostics
| where ResourceProvider == "MICROSOFT.NETWORK"
| where Category in ("AzureFirewallNetworkRule", "AZFWNetworkRule") or Category contains "Firewall"
| where msg_s has "Deny"
| take 50| Area | Resources |
|---|---|
| Resource groups | hub, test, protected, security |
| Networking | 3 VNets, subnets, peerings, NSGs, route tables |
| Security | Azure Firewall Basic, firewall policy, rule collections |
| Compute | 1 test Windows VM, 1 protected Windows IIS web VM |
| Secrets | Key Vault, generated Windows admin password, lab output secrets |
| Monitoring | Log Analytics workspace, firewall diagnostics, Key Vault diagnostics |
| Governance | custom audit policies and assignments |
| Cost | subscription budget alert |
| Profile | Key toggles | Approximate cost behavior |
|---|---|---|
| Minimal private test | deploy_firewall=false |
Low cost, no firewall scenario |
| Default security lab | deploy_firewall=true, deploy_bastion=false |
Firewall is the main cost |
| Private access lab | deploy_bastion=true, enable_test_client_public_ip=false |
More secure access, more cost |
| Identity lab | deploy_entra_test_users=true |
Requires Graph permissions |
| SOC lab | deploy_sentinel=true |
Higher detection value, possible ingestion cost |
- Terraform
>= 1.9.0 - Azure CLI
- An Azure subscription
- Contributor rights for the subscription or target scope
- Optional: Entra permissions if creating test users or Conditional Access
az login
az account set --subscription "<subscription-id>"Copy the example file:
Copy-Item terraform.tfvars.example terraform.tfvarsSet your public IP:
admin_source_ip_cidr = "YOUR_PUBLIC_IP/32"terraform init
terraform plan -var-file="terraform.tfvars"
terraform apply -var-file="terraform.tfvars"terraform output test_client
terraform output protected_web
terraform output -raw windows_admin_passwordThe Windows admin password is sensitive. You can also retrieve it from Key Vault when deploy_keyvault = true.
terraform destroy -var-file="terraform.tfvars"The lab was applied, tested, and destroyed on June 7, 2026 from this workspace against the active Azure subscription.
| Check | Result |
|---|---|
| Toolchain | Terraform 1.12.0, Azure CLI 2.83.0 |
| Static checks | terraform fmt -check -recursive passed; terraform validate passed |
| Plan | Initial Windows plan: 69 to add, 0 to change, 0 to destroy; recovery plan after the hostname fix: 4 to add, 0 to change, 0 to destroy |
| Apply | Shared lab resources were created first; recovery apply created the two Windows VMs and Custom Script Extensions |
| Runtime test | Protected IIS active; test client HTTP to protected private IP succeeded; TCP/3389 to protected VM returned ADMIN_PORT_BLOCKED |
| Telemetry | Log Analytics showed firewall Allow for test-to-protected-web and Deny for deny-test-to-protected-admin |
| Destroy | 69 destroyed; all four default resource groups returned false; generated local state and plan files were removed |
See the full environment test report for exact commands, findings, and the Log Analytics REST workaround used for this Azure CLI install.
The repo includes .github/workflows/terraform-checks.yml for non-destructive
static checks:
- Terraform formatting
- Terraform backend-free init and validation
- TFLint
- Checkov
- Gitleaks
The workflow runs on pushes to main, pull requests, and manual dispatch. It
does not apply or destroy Azure resources.
No Azure credentials are required for the default checks. If you later add a plan workflow, use GitHub Actions OIDC with Azure and remote Terraform state.
| Variable | Purpose |
|---|---|
ARM_CLIENT_ID |
App registration or managed identity client ID for optional Azure OIDC workflows |
ARM_TENANT_ID |
Entra tenant ID for optional Azure OIDC workflows |
ARM_SUBSCRIPTION_ID |
Azure subscription ID for optional Azure OIDC workflows |
DESTROY_CONFIRM |
Recommended explicit guard if a future manual destroy workflow is added |
The current backend is local for easy workstation use. Before shared CI
plan/apply/destroy usage, replace backend.tf with an Azure Storage backend so
pipeline state is durable.
Important variables:
| Variable | Default | Description |
|---|---|---|
project |
security |
Naming prefix |
environment |
lab |
Environment label |
location |
westeurope |
Azure region |
deploy_firewall |
true |
Deploy Azure Firewall |
firewall_sku_tier |
Basic |
Firewall SKU |
admin_source_ip_cidr |
0.0.0.0/32 |
Allowed RDP source |
enable_test_client_public_ip |
true |
Public RDP path to test client |
deploy_bastion |
false |
Optional private access |
deploy_entra_test_users |
false |
Optional Entra users/groups |
deploy_conditional_access |
false |
Optional report-only MFA policy |
deploy_sentinel |
false |
Optional Sentinel onboarding |
deploy_keyvault |
true |
Store generated lab secrets |
deploy_log_analytics |
true |
Enable central logs |
deploy_policy |
true |
Enable audit policies |
deploy_budget |
true |
Enable budget alert |
terraform fmt -check -recursive
terraform init -backend=false -input=false
terraform validateBefore plan, apply, or destroy, initialize the configured local backend:
terraform init -input=falseOptional tools:
tflint --init
tflint --recursive
checkov -d . --framework terraform
gitleaks detect --source . --redactterraform plan -var-file="environments/lab.tfvars"Review that:
- Azure Firewall appears only when
deploy_firewall = true. - Protected VM has no public IP.
- Test and protected route tables point private spoke traffic at the firewall private IP.
- Entra users are skipped when
deploy_entra_test_users = false.
From the test client:
Invoke-WebRequest -UseBasicParsing "http://PROTECTED_PRIVATE_IP"
Test-NetConnection PROTECTED_PRIVATE_IP -Port 3389Expected:
- HTTP succeeds.
- RDP to protected fails.
Integration tests live in tests/.
cd tests
go test ./...Tests skip automatically when ARM_SUBSCRIPTION_ID is not set.
- Private protected workload with no public IP.
- Restricted public RDP to test client via
admin_source_ip_cidr. - NSG deny rules for administrative protocols.
- Firewall policy allow and deny examples.
- Key Vault secret storage for generated Windows admin password and lab values.
- Log Analytics diagnostics for firewall and Key Vault.
- Audit-mode Azure Policy assignments.
- Optional Entra test identity and report-only Conditional Access.
- Optional Sentinel onboarding.
- GitHub Actions validation, linting, secret scanning, and security scanning.
Costs vary by region and usage. The largest default cost is Azure Firewall Basic.
| Resource | Default | Cost behavior |
|---|---|---|
| Azure Firewall Basic | On | Roughly 0.40 USD/hour in West Europe plus data |
| 2 small Windows VMs | On | Low, but still billable while running |
| Log Analytics | On | Controlled by log_analytics_daily_quota_gb |
| Key Vault | On | Usually low for lab usage |
| Bastion | Off | Adds hourly cost when enabled |
| Sentinel | Off | Can add ingestion/analytics cost |
Cost tips:
- Destroy the lab after practice.
- Keep
deploy_bastion = falseunless you need private access. - Keep
deploy_sentinel = falseuntil you are ready for detection scenarios. - Use the budget alert as an early warning, not as a hard cost limit.
- Consider
deploy_firewall = falsewhen working only on docs, policy, or identity modules.
- Confirm
enable_test_client_public_ip = true. - Confirm
admin_source_ip_cidris your current public IP with/32. - Confirm the test client public IP from
terraform output test_client.
If force_spoke_egress_to_firewall = true, public RDP return traffic can become asymmetric. Use Bastion/private access, or keep the default false.
- Confirm the protected VM private IP from
terraform output protected_web. - Confirm route tables exist when
deploy_firewall = true. - Confirm firewall rules allow TCP 80/443 from
10.23.0.0/16to10.22.0.0/16. - Confirm Custom Script Extension completed on the protected VM.
- Set
deploy_entra_test_users = falseif you do not need identity scenarios. - Set
entra_domain_nameto a verified tenant domain. - Confirm the Terraform identity has permission to create users and groups.
Conditional Access requires Entra ID P1/P2 licensing and the right Graph permissions. It is disabled by default.
Budget APIs can require subscription-level permissions. Set deploy_budget = false if your account cannot create budgets.
.
|-- .github/workflows/terraform-checks.yml
|-- README.md
|-- backend.tf
|-- environments/
|-- docs/images/
|-- modules/
| |-- bastion/
| |-- budget/
| |-- conditional-access/
| |-- entra-test-identity/
| |-- firewall/
| |-- keyvault/
| |-- windows-vm/
| |-- monitoring/
| |-- networking/
| |-- policy/
| `-- sentinel/
|-- policies/
|-- tests/
|-- wiki/
|-- main.tf
|-- variables.tf
|-- outputs.tf
`-- terraform.tfvars.example
- Azure Firewall SKU features: https://learn.microsoft.com/azure/firewall/features-by-sku
- Azure Retail Prices API: https://learn.microsoft.com/rest/api/cost-management/retail-prices/azure-retail-prices
- Conditional Access planning: https://learn.microsoft.com/entra/identity/conditional-access/plan-conditional-access
- GitHub Actions OIDC with Azure: https://learn.microsoft.com/azure/developer/github/connect-from-azure-openid-connect
MIT. See LICENSE.