IaC Azure

Terraform Module: Azure Defender for Cloud — One Plan-Per-Resource-Type Control Plane

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

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 configlive/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 configlive/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

TerraformAzureDefender for CloudModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading