Quick take — Reusable hashicorp/azurerm module for an Azure DDoS Network Protection Plan: provision the plan once, share its ID across VNets, and avoid the per-VNet billing trap. Inputs, outputs, and production patterns. 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 "ddos_protection" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-ddos-protection?ref=v1.0.0"
name = "..." # Name of the DDoS Network Protection Plan (3-80 chars). …
location = "..." # Azure region for the plan resource. Associated VNets ma…
resource_group_name = "..." # Existing resource group (typically connectivity/hub) th…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Azure DDoS Network Protection Plan is the billable container that turns on Azure’s always-on, adaptive Layer 3/4 DDoS mitigation for the public IPs attached to a virtual network. You create one plan, then associate virtual networks with it; every public IP inside an associated VNet inherits volumetric and protocol attack mitigation, adaptive tuning, attack telemetry, traffic-flow logs, and access to the DDoS Rapid Response team.
The single most important thing to understand about this resource is its billing model, and it is exactly why you want a module. A DDoS Network Protection Plan has a flat monthly charge (roughly $2,944/month) that covers protection for up to 100 public IP addresses, plus overage per protected IP beyond that. That charge is per plan, not per VNet. The catastrophic anti-pattern — which is trivially easy to hit if every team owns its own networking Terraform — is teams each declaring their own azurerm_network_ddos_protection_plan, turning one ~$3k/month line item into five.
Wrapping the plan in one tightly-scoped module gives you a single, governed point of truth: the plan is created exactly once (typically in a connectivity/hub subscription), it exports a stable id, and every spoke VNet — in any subscription — references that one ID. The module keeps the plan’s naming, tags, and lifecycle consistent, and makes “are we accidentally paying for this twice?” answerable with a single terraform state list.
When to use it
Use this module when:
- You run internet-facing workloads on public IPs (Application Gateway/WAF, public Load Balancer frontends, Azure Firewall, public-facing VMs) and need protection beyond the free DDoS IP Protection SKU’s per-IP model.
- You operate a hub-and-spoke or Azure Landing Zone topology and want one shared Network Protection Plan consumed by many spokes across subscriptions.
- You have compliance or contractual requirements (cost protection / SLA guarantees, attack analytics, Rapid Response engagement) that the network-tier plan satisfies and per-IP protection does not.
- You want to centralize the ~$3k/month spend behind a single Terraform resource so FinOps can see and govern it.
Reach for the DDoS IP Protection SKU instead (configured directly on the azurerm_public_ip, not via this plan) when you have only a handful of public IPs and want pay-per-IP economics rather than a flat plan fee. Skip DDoS protection plans entirely for purely private workloads with no public IPs — there is nothing for the plan to protect.
Module structure
terraform-module-azure-ddos-protection/
├── versions.tf # provider + Terraform version pins
├── main.tf # the DDoS Network Protection Plan
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id/name + key attributes for downstream wiring
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
# A DDoS Network Protection Plan is a flat-fee, regional-resource-group-scoped
# object. It is created ONCE and shared by associating VNets with its id.
# The association itself lives on the virtual network resource
# (azurerm_virtual_network.ddos_protection_plan), NOT here — this module
# intentionally owns only the plan so the plan's lifecycle is decoupled
# from any single network.
resource "azurerm_network_ddos_protection_plan" "this" {
name = var.name
location = var.location
resource_group_name = var.resource_group_name
tags = var.tags
}
variables.tf
variable "name" {
description = "Name of the DDoS Network Protection Plan. A subscription is limited to one plan per region, so encode the region/scope in the name."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{1,78}[a-zA-Z0-9_]$", var.name))
error_message = "name must be 3-80 chars, start with a letter or number, and contain only letters, numbers, periods, underscores or hyphens."
}
}
variable "location" {
description = "Azure region for the plan (e.g. \"westeurope\"). Associated VNets do NOT have to be in this region, but the plan resource itself is regional."
type = string
validation {
condition = length(trimspace(var.location)) > 0
error_message = "location must be a non-empty Azure region name."
}
}
variable "resource_group_name" {
description = "Name of an existing resource group to hold the plan. Typically a connectivity/hub resource group."
type = string
validation {
condition = length(var.resource_group_name) >= 1 && length(var.resource_group_name) <= 90
error_message = "resource_group_name must be between 1 and 90 characters."
}
}
variable "tags" {
description = "Tags applied to the plan. Strongly recommend a cost-center / owner tag because this resource carries a flat ~$2,944/month charge."
type = map(string)
default = {}
validation {
condition = alltrue([for k in keys(var.tags) : length(k) > 0])
error_message = "Tag keys must be non-empty strings."
}
}
outputs.tf
output "id" {
description = "Resource ID of the DDoS Network Protection Plan. Set this as ddos_protection_plan.id on every spoke azurerm_virtual_network you want protected."
value = azurerm_network_ddos_protection_plan.this.id
}
output "name" {
description = "Name of the DDoS Network Protection Plan."
value = azurerm_network_ddos_protection_plan.this.name
}
output "resource_group_name" {
description = "Resource group that contains the plan."
value = azurerm_network_ddos_protection_plan.this.resource_group_name
}
output "location" {
description = "Region of the plan resource."
value = azurerm_network_ddos_protection_plan.this.location
}
output "virtual_network_ids" {
description = "IDs of virtual networks currently associated with this plan (computed by Azure as VNets opt in)."
value = azurerm_network_ddos_protection_plan.this.virtual_network_ids
}
How to use it
Create the plan once in your connectivity subscription, then have each spoke VNet point its ddos_protection_plan block at the module’s id output.
module "ddos_protection_plan" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-ddos-protection?ref=v1.0.0"
name = "ddos-plan-weu-connectivity"
location = "westeurope"
resource_group_name = azurerm_resource_group.connectivity.name
tags = {
environment = "shared"
cost_center = "platform-networking"
owner = "cloud-platform-team"
managed_by = "terraform"
}
}
# Downstream: a spoke VNet opts in by referencing the plan's id output.
# The enable_protection flag must be true for the association to take effect.
resource "azurerm_virtual_network" "spoke_prod" {
name = "vnet-prod-weu-01"
location = "westeurope"
resource_group_name = azurerm_resource_group.spoke_prod.name
address_space = ["10.20.0.0/16"]
ddos_protection_plan {
id = module.ddos_protection_plan.id
enable = true
}
}
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/ddos_protection/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-ddos-protection?ref=v1.0.0"
}
inputs = {
name = "..."
location = "..."
resource_group_name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/ddos_protection && 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 |
|---|---|---|---|---|
name |
string |
n/a | Yes | Name of the DDoS Network Protection Plan (3-80 chars). A subscription allows one plan per region, so encode region/scope in the name. |
location |
string |
n/a | Yes | Azure region for the plan resource. Associated VNets may live in other regions. |
resource_group_name |
string |
n/a | Yes | Existing resource group (typically connectivity/hub) that holds the plan. |
tags |
map(string) |
{} |
No | Tags applied to the plan; include cost-center/owner because of the flat monthly charge. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the plan; assign to ddos_protection_plan.id on each protected VNet. |
name |
Name of the DDoS Network Protection Plan. |
resource_group_name |
Resource group containing the plan. |
location |
Region of the plan resource. |
virtual_network_ids |
IDs of VNets currently associated with the plan (computed by Azure). |
Enterprise scenario
A retail bank running an Azure Landing Zone has its connectivity subscription deploy this module once to create ddos-plan-weu-connectivity. The plan’s id output is published to a Terraform remote state (or a shared tfvars/parameter), and all 40+ spoke VNets across the corp, online-banking, and payments management groups reference that single ID in their ddos_protection_plan blocks. The result: every internet-facing Application Gateway and Azure Firewall public IP in the estate is protected under one ~$3k/month plan instead of dozens, FinOps sees exactly one DDoS line item, and the security team can engage DDoS Rapid Response with a single supported plan during an attack.
Best practices
- One plan per region, period. Azure caps you at one Network Protection Plan per subscription per region, and the fee is flat. Deploy this module in a central connectivity subscription and share the
ideverywhere — never let spoke teams instantiate their own plan. - Decouple the plan from the association. Keep plan creation (this module) separate from the VNet’s
ddos_protection_plan { id = ...; enable = true }block. Destroying or re-creating a spoke VNet must never risk the shared, expensive plan. - Tag for FinOps from day one. Because the plan carries a ~$2,944/month flat charge covering 100 IPs, apply
cost_centerandownertags so the spend is attributable and overage past 100 protected IPs is reviewed deliberately. - Right-size the SKU choice. If you protect only a few public IPs, the pay-per-IP DDoS IP Protection SKU (set on
azurerm_public_ip) is cheaper than this plan — only adopt the network plan when shared scale or compliance justifies the flat fee. - Wire telemetry, not just protection. Send the plan’s DDoS metrics and
DDoSProtectionNotifications/ flow logs to Log Analytics and configure alerts (Under DDoS attack or not) so an in-progress mitigation is visible to your SOC. - Pin the module by tag. Always consume it via
?ref=v1.0.0; an unpinnedgit::source against a resource this costly is an avoidable change-control risk.