Quick take — Reusable hashicorp/azurerm ~> 4.0 module to enable Microsoft Defender for Cloud plans per subscription with azurerm_security_center_subscription_pricing, sub-plan extensions, and contact config. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "defender" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-defender?ref=v1.0.0"
subscription_id = "..." # Subscription GUID this baseline targets; scopes the opt…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Microsoft Defender for Cloud is Azure’s cloud-native application protection platform (CNAPP). It has two layers: a free Cloud Security Posture Management (CSPM) layer that scores your subscription against the Microsoft Cloud Security Benchmark, and a set of paid Defender plans that bolt on workload protection — threat detection for VMs (Defender for Servers), agentless container scanning and runtime protection (Defender for Containers), SQL vulnerability assessment and anomaly alerts (Defender for SQL/Databases), storage malware scanning (Defender for Storage), Key Vault access anomaly detection, and more.
The catch is that each plan is enabled independently, per subscription, through its own azurerm_security_center_subscription_pricing resource. There is no single “turn on Defender” switch. A landing zone with 40 subscriptions and 8 plans is 320 individual on/off decisions, each with a Standard/Free tier, an optional subplan (e.g. Defender for Servers Plan 1 vs Plan 2, Defender for Storage PerStorageAccount vs DefenderForStorageV2), and extension blocks for features like agentless scanning or sensitive-data discovery. Click-ops that across a tenant is how you end up with one subscription silently un-protected and a six-figure breach.
This module wraps the plan-pricing surface in one var-driven unit. You pass a map of plan names to their tier/subplan/extensions, plus optional security-contact wiring, and the module fans it out into the right resources for the subscription the provider is pointed at. That makes Defender coverage a reviewable Terraform plan you can roll out identically across every subscription via for_each at the root, rather than a tribal-knowledge checklist.
When to use it
- You manage more than one Azure subscription and need uniform, auditable Defender plan coverage instead of per-subscription portal clicks.
- You want plan enablement (and the cost it carries) to live in code so cost owners can review which plans are on before they ship.
- You need to standardize sub-plans and extensions — e.g. Defender for Servers Plan 2 with vulnerability assessment, or Defender for Storage V2 with on-upload malware scanning — across a fleet.
- You are building an enterprise-scale landing zone and want a per-subscription “security baseline” module that the subscription-vending pipeline applies automatically.
- You want to turn specific plans off in dev/sandbox (back to
Free) and on in prod from the same module, driven only bytfvars.
Skip it if you only ever touch a single subscription and are happy clicking the Environment Settings blade — the module’s value is repeatability across many subscriptions.
Module structure
terraform-module-azure-defender/
├── versions.tf # provider + Terraform version pins
├── main.tf # plan pricing + security contact resources
├── variables.tf # plan map, contact, validations
└── outputs.tf # plan ids/tiers + summary maps
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
# One azurerm_security_center_subscription_pricing per requested plan.
# The resource is subscription-scoped: it acts on whatever subscription
# the azurerm provider passed into this module is authenticated against.
resource "azurerm_security_center_subscription_pricing" "this" {
for_each = var.defender_plans
tier = each.value.tier
resource_type = each.key
subplan = each.value.subplan
dynamic "extension" {
for_each = { for ext in each.value.extensions : ext.name => ext }
content {
name = extension.value.name
additional_extension_properties = extension.value.additional_properties
}
}
}
# Optional: who gets the high-severity alert emails for this subscription.
resource "azurerm_security_center_contact" "this" {
count = var.security_contact == null ? 0 : 1
name = "default"
email = var.security_contact.email
phone = var.security_contact.phone
alert_notifications = var.security_contact.alert_notifications
alerts_to_admins = var.security_contact.alerts_to_admins
}
# Optional: point Defender's continuous export / posture at a specific
# Log Analytics workspace instead of the auto-provisioned default one.
resource "azurerm_security_center_workspace" "this" {
count = var.log_analytics_workspace_id == null ? 0 : 1
scope = "/subscriptions/${var.subscription_id}"
workspace_id = var.log_analytics_workspace_id
}
variables.tf
variable "subscription_id" {
description = "Subscription GUID this baseline targets. Used to scope the optional workspace binding."
type = string
validation {
condition = can(regex("^[0-9a-fA-F-]{36}$", var.subscription_id))
error_message = "subscription_id must be a 36-character subscription GUID."
}
}
variable "defender_plans" {
description = <<-EOT
Map of Defender plan resource_type => settings. Keys are the exact
Defender plan names Azure expects, e.g. "VirtualMachines", "Containers",
"StorageAccounts", "SqlServers", "KeyVaults", "AppServices", "Arm",
"Api", "CosmosDbs", "OpenSourceRelationalDatabases".
EOT
type = map(object({
tier = string
subplan = optional(string, null)
extensions = optional(list(object({
name = string
additional_properties = optional(map(string), null)
})), [])
}))
default = {}
validation {
condition = alltrue([
for p in values(var.defender_plans) : contains(["Free", "Standard"], p.tier)
])
error_message = "Each plan tier must be either \"Free\" or \"Standard\"."
}
validation {
condition = alltrue([
for k, p in var.defender_plans :
p.subplan == null || k == "VirtualMachines" || k == "StorageAccounts" ||
k == "Containers" || k == "CloudPosture" || k == "Api"
])
error_message = "subplan is only valid for VirtualMachines, StorageAccounts, Containers, CloudPosture, or Api plans."
}
validation {
condition = alltrue([
for k, p in var.defender_plans :
k != "VirtualMachines" || p.subplan == null || contains(["P1", "P2"], p.subplan)
])
error_message = "Defender for Servers (VirtualMachines) subplan must be \"P1\" or \"P2\"."
}
}
variable "security_contact" {
description = "Optional security contact for high-severity alert notifications. Set null to leave existing contact untouched."
type = object({
email = string
phone = optional(string, "")
alert_notifications = optional(bool, true)
alerts_to_admins = optional(bool, true)
})
default = null
validation {
condition = var.security_contact == null || can(regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", var.security_contact.email))
error_message = "security_contact.email must be a valid email address."
}
}
variable "log_analytics_workspace_id" {
description = "Optional Log Analytics workspace resource ID to bind Defender data collection to. Null uses the Defender-managed default workspace."
type = string
default = null
}
outputs.tf
output "plan_ids" {
description = "Map of Defender plan resource_type => azurerm_security_center_subscription_pricing resource ID."
value = { for k, p in azurerm_security_center_subscription_pricing.this : k => p.id }
}
output "enabled_standard_plans" {
description = "List of plan resource_types currently set to the Standard (paid) tier."
value = [for k, p in azurerm_security_center_subscription_pricing.this : k if p.tier == "Standard"]
}
output "plan_tiers" {
description = "Map of plan resource_type => effective tier, for downstream policy/compliance checks."
value = { for k, p in azurerm_security_center_subscription_pricing.this : k => p.tier }
}
output "security_contact_id" {
description = "Resource ID of the security contact, or null if none was managed by this module."
value = try(azurerm_security_center_contact.this[0].id, null)
}
How to use it
data "azurerm_client_config" "current" {}
module "defender_for_cloud" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-defender?ref=v1.0.0"
subscription_id = data.azurerm_client_config.current.subscription_id
log_analytics_workspace_id = azurerm_log_analytics_workspace.security.id
security_contact = {
email = "soc@kloudvin.io"
alert_notifications = true
alerts_to_admins = true
}
defender_plans = {
# Defender for Servers Plan 2 (full EDR + vuln assessment + FIM)
VirtualMachines = {
tier = "Standard"
subplan = "P2"
}
# Agentless container posture + runtime protection
Containers = {
tier = "Standard"
}
# Storage V2 with on-upload malware scanning + sensitive data discovery
StorageAccounts = {
tier = "Standard"
subplan = "DefenderForStorageV2"
extensions = [
{ name = "OnUploadMalwareScanning" },
{ name = "SensitiveDataDiscovery" },
]
}
SqlServers = { tier = "Standard" }
KeyVaults = { tier = "Standard" }
Arm = { tier = "Standard" }
# Cheaper subs: leave App Service protection off
AppServices = { tier = "Free" }
}
}
# Downstream: alert if a required plan ever drops to Free using plan_tiers output.
resource "azurerm_monitor_activity_log_alert" "servers_plan_downgraded" {
count = module.defender_for_cloud.plan_tiers["VirtualMachines"] == "Standard" ? 1 : 0
name = "alert-defender-servers-enabled"
resource_group_name = azurerm_resource_group.security.name
location = "global"
scopes = [azurerm_log_analytics_workspace.security.id]
criteria {
category = "Security"
operation_name = "Microsoft.Security/pricings/write"
}
action {
action_group_id = azurerm_monitor_action_group.soc.id
}
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/defender/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-defender?ref=v1.0.0"
}
inputs = {
subscription_id = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/defender && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
subscription_id |
string |
— | Yes | Subscription GUID this baseline targets; scopes the optional workspace binding. |
defender_plans |
map(object({ tier, subplan, extensions })) |
{} |
No | Map of Defender plan resource_type to tier (Free/Standard), optional subplan, and extension blocks. |
security_contact |
object({ email, phone, alert_notifications, alerts_to_admins }) |
null |
No | Security contact for high-severity alert emails. null leaves any existing contact untouched. |
log_analytics_workspace_id |
string |
null |
No | Log Analytics workspace ID to bind Defender data collection to; null uses the Defender-managed default. |
Outputs
| Name | Description |
|---|---|
plan_ids |
Map of plan resource_type to its azurerm_security_center_subscription_pricing resource ID. |
enabled_standard_plans |
List of plan resource_types currently on the Standard (paid) tier. |
plan_tiers |
Map of plan resource_type to effective tier, for downstream policy/compliance checks. |
security_contact_id |
Resource ID of the managed security contact, or null if none. |
Enterprise scenario
A financial-services group runs a 60-subscription enterprise-scale landing zone where every workload subscription must carry Defender for Servers P2, Defender for SQL, and Defender for Storage V2 with malware scanning to satisfy a PCI-DSS control. The subscription-vending pipeline calls this module once per new subscription with a shared defender_plans baseline, so a freshly minted subscription is protected within the same Terraform apply that creates it — no manual portal step, and enabled_standard_plans is exported into the platform inventory so the GRC team can prove coverage across all 60 subscriptions from a single state read.
Best practices
- Treat
tier = "Standard"as a cost line item, not a default. Each Standard plan bills per resource-hour (per vCore, per storage account, per node). Drive plan selection from environment tier — Standard in prod,Freein sandbox — and let cost owners review thedefender_plansmap in PR, sinceenabled_standard_plansmakes the spend explicit. - Pick sub-plans deliberately. Defender for Servers
P1covers Microsoft Defender Antivirus and just-in-time access;P2adds vulnerability assessment, file integrity monitoring, and 500 GB/day free data ingestion. Defender for Storage’sDefenderForStorageV2(per-storage-account, transaction-cost-free) is almost always preferable to the legacyPerTransactionmodel — encode that choice intfvars, not in someone’s memory. - Always wire a
security_contact. Without it, high- and critical-severity Defender alerts have no email destination and quietly pile up in the portal. Point it at a monitored SOC distribution list, never an individual’s mailbox. - Enable the
ArmandApiplans for control-plane and API coverage, not just compute. Defender for Resource Manager catches malicious management-plane operations (e.g. credential dumping via deployment scripts) that VM/container plans never see. - Standardize the workspace binding. Pointing
log_analytics_workspace_idat one central security workspace per landing zone keeps Defender telemetry queryable alongside other security logs instead of scattered across per-subscription default workspaces. - Never let plans drift. Run
terraform planon a schedule (or via the activity-log alert shown above) so a plan silently flipped toFreein the portal is surfaced as drift and re-applied, keeping every subscription at its mandated baseline.