Last reviewed: May 2026
Build the AWS services on the AZ-500 exam with plain Terraform — one block at a time, each tied back to an exam domain. The same code works on OpenTofu.
By the end of this lab you'll have provisioned, with plain Terraform, the Azure security-engineer baseline — a Key Vault hosting a customer-managed encryption key, a Storage Account encrypted with that key, a Network Security Group with flow logs into Log Analytics, and Microsoft Defender for Cloud's Cloud Security Posture Management plan enabled at subscription scope. Five blocks; the AZ-500 reference posture.
Drop the snippets into a single main.tf, run terraform init, then terraform apply step-by-step.
>= 1.5 or OpenTofu >= 1.6.az login).Mostly free or near-free:
~$1/month idle. Defender for Cloud advanced plans cost extra if you enable them.
Standard Azure opener.
terraform {
required_version = ">= 1.5"
required_providers {
azurerm = { source = "hashicorp/azurerm", version = "~> 4.0" }
random = { source = "hashicorp/random", version = "~> 3.6" }
}
}
provider "azurerm" {
features {
key_vault {
purge_soft_delete_on_destroy = true
}
}
}
resource "random_id" "suffix" {
byte_length = 3
}
data "azurerm_client_config" "current" {}
data "azurerm_subscription" "current" {}
locals {
tags = {
Project = "certlabpro-az-500"
ManagedBy = "terraform"
}
}
resource "azurerm_resource_group" "main" {
name = "certlabpro-az-500-rg"
location = "eastus"
tags = local.tags
}Customer-managed keys (CMK) are the AZ-500 Data Security primitive: you provide and rotate the encryption key, Azure storage/SQL/etc. uses it for envelope encryption. Compare to Microsoft-managed keys (MMK) — easier but Microsoft holds the key.
We create a Key Vault with RBAC authorization, then create a 2048-bit RSA key inside it. The Storage Account in Step 3 will reference this key for at-rest encryption. AZ-500's Implement platform protection and Manage security operations domains both lean on this CMK pattern as the encryption-at-rest answer when the question mentions "organization controls keys".
resource "azurerm_key_vault" "main" {
name = "kv-az500-${random_id.suffix.hex}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "standard"
enable_rbac_authorization = true
soft_delete_retention_days = 7
purge_protection_enabled = false # set true in production
tags = local.tags
}
resource "azurerm_role_assignment" "kv_admin_self" {
scope = azurerm_key_vault.main.id
role_definition_name = "Key Vault Administrator"
principal_id = data.azurerm_client_config.current.object_id
}
resource "azurerm_key_vault_key" "cmk" {
name = "storage-cmk"
key_vault_id = azurerm_key_vault.main.id
key_type = "RSA"
key_size = 2048
key_opts = ["unwrapKey", "wrapKey"]
depends_on = [azurerm_role_assignment.kv_admin_self]
rotation_policy {
automatic {
time_before_expiry = "P30D"
}
expire_after = "P365D"
notify_before_expiry = "P30D"
}
}Storage accounts using customer-managed keys need a user-assigned identity to read the key from Key Vault. We create that identity, grant it Key Vault Crypto Service Encryption User on the Key Vault, then bind it to the storage account via the customer_managed_key block.
The key_versionless mode (using customer_managed_key.key_vault_key_id = key_vault_key.cmk.versionless_id) is the AZ-500 best-practice answer for automatic key-rotation: when the key rotates, storage automatically picks up the new version without manual reconfiguration.
resource "azurerm_user_assigned_identity" "storage_cmk" {
name = "uami-az500-storage"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
tags = local.tags
}
resource "azurerm_role_assignment" "storage_kv_user" {
scope = azurerm_key_vault.main.id
role_definition_name = "Key Vault Crypto Service Encryption User"
principal_id = azurerm_user_assigned_identity.storage_cmk.principal_id
}
resource "azurerm_storage_account" "data" {
name = "az500data${random_id.suffix.hex}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
account_tier = "Standard"
account_replication_type = "LRS"
account_kind = "StorageV2"
https_traffic_only_enabled = true
min_tls_version = "TLS1_2"
allow_nested_items_to_be_public = false
identity {
type = "UserAssigned"
identity_ids = [azurerm_user_assigned_identity.storage_cmk.id]
}
customer_managed_key {
key_vault_key_id = azurerm_key_vault_key.cmk.versionless_id
user_assigned_identity_id = azurerm_user_assigned_identity.storage_cmk.id
}
tags = local.tags
depends_on = [azurerm_role_assignment.storage_kv_user]
}NSG flow logs are the AZ-500 Manage security operations primitive for network-traffic visibility — they record every flow accepted or denied by an NSG rule (5-tuple + bytes + packets + result), persisted to a storage account and queryable via Traffic Analytics on Log Analytics.
We provision the NSG, a Network Watcher (the regional Azure-managed service that owns the flow-log endpoint), and a flow log resource with Traffic Analytics enabled. The flow logs land in a storage account (separate from our data storage) and are processed into Log Analytics for KQL queries.
resource "azurerm_network_security_group" "main" {
name = "nsg-az500"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
tags = local.tags
}
resource "azurerm_network_watcher" "main" {
name = "nw-az500"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
tags = local.tags
}
resource "azurerm_storage_account" "flow_logs" {
name = "az500flow${random_id.suffix.hex}"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
account_tier = "Standard"
account_replication_type = "LRS"
account_kind = "StorageV2"
https_traffic_only_enabled = true
min_tls_version = "TLS1_2"
allow_nested_items_to_be_public = false
tags = local.tags
}
resource "azurerm_log_analytics_workspace" "main" {
name = "log-az500"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
sku = "PerGB2018"
retention_in_days = 30
tags = local.tags
}
resource "azurerm_network_watcher_flow_log" "main" {
name = "nsg-az500-flow"
network_watcher_name = azurerm_network_watcher.main.name
resource_group_name = azurerm_resource_group.main.name
target_resource_id = azurerm_network_security_group.main.id
storage_account_id = azurerm_storage_account.flow_logs.id
enabled = true
version = 2
retention_policy {
enabled = true
days = 30
}
traffic_analytics {
enabled = true
workspace_id = azurerm_log_analytics_workspace.main.workspace_id
workspace_region = azurerm_log_analytics_workspace.main.location
workspace_resource_id = azurerm_log_analytics_workspace.main.id
interval_in_minutes = 10
}
}Defender for Cloud is Azure's CSPM (Cloud Security Posture Management) and CWP (Cloud Workload Protection) service. The Foundational CSPM tier is free and includes the Microsoft Cloud Security Benchmark assessment — Azure's port of CIS / NIST controls into automated checks. AZ-500's Manage security operations domain tests this baseline as the day-one security visibility primitive.
We enable the foundational subscription-scope pricing plan (free) and a auto_provisioning_setting that hooks the Log Analytics agent on new VMs automatically — meaning new compute in this subscription gets observability without manual install. Paid Defender plans (Defender for Servers, Defender for Storage, Defender for SQL, etc.) each enable per-resource-type detection; we leave them off here for cost but mention them as the follow-up question.
With this final piece in place, the AZ-500 baseline is shaped: CMK encryption at rest, network-flow visibility, posture management. Every additional AZ-500 pattern (Sentinel, Conditional Access, Private Endpoints, Azure Firewall) attaches to this base.
resource "azurerm_security_center_subscription_pricing" "cspm" {
tier = "Free" # Foundational CSPM
resource_type = "CloudPosture"
}
# Optional uncomment to enable paid Defender plans (each charges per resource).
# resource "azurerm_security_center_subscription_pricing" "servers" {
# tier = "Standard"
# resource_type = "VirtualMachines"
# subplan = "P2"
# }terraform destroy tears down everything. Notes:
purge_soft_delete_on_destroy = true in the provider features makes destroy actually purge.AZ-500 covers a broad security surface this lab can't fit — Microsoft Sentinel (the full SIEM, covered in SC-200), Microsoft Entra Conditional Access policies, Privileged Identity Management (PIM), Identity Protection, Azure Firewall + Firewall Policy + Premium IDPS, Azure Bastion, DDoS Protection Standard, Web Application Firewall on Front Door / App Gateway, Application Security Groups, Private Endpoints + Private Link Service, customer-lockbox, Azure Disk Encryption with KEK, Azure Policy initiatives for compliance, and the Defender for Cloud Workload Protection plans (Defender for Servers / Storage / SQL / Containers / Kubernetes).
We stick to the CMK + network flow logs + CSPM triad because they're the substrate every more-advanced AZ-500 pattern composes on top of. Sentinel reads from Log Analytics workspaces like ours. Conditional Access protects identities accessing CMK-encrypted storage. Private Endpoints replace public network access on the storage account.
For the surfaces above, see the Browse, Playbook, and Editorial sections of this cert page.