IaC Azure

Terraform Module: Azure Management Lock — guard rails that survive a bad `terraform destroy`

Quick take — A reusable hashicorp/azurerm ~> 4.0 module for azurerm_management_lock that applies CanNotDelete or ReadOnly locks at subscription, resource-group, or resource scope with safe naming and notes. 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 "management_lock" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-management-lock?ref=v1.0.0"

  scope_id = "..."  # ARM resource ID to lock (subscription, resource group, …
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An Azure Management Lock is a control-plane guard rail. Once you place a lock on a scope — a subscription, a resource group, or a single resource — Azure Resource Manager rejects mutating operations that would otherwise succeed, regardless of the caller’s RBAC role. There are exactly two lock levels:

The Terraform resource is azurerm_management_lock. On its own it is a tiny resource — a name, a level, a scope, and an optional note — but in practice it is fiddly to use safely: the scope has to be the ARM resource ID of whatever you are protecting, locks inherit downward (a subscription lock applies to every resource group and resource beneath it), and a stray ReadOnly lock will silently break your next Terraform run because Terraform itself can no longer write to the scope.

Wrapping it in a module gives you one vetted, var-driven place to:

When to use it

Reach for this module whenever a resource’s accidental deletion or mutation would cause real damage and RBAC alone is not a strong enough barrier:

Avoid ReadOnly on anything Terraform itself still manages unless you are prepared to remove the lock before every apply — that is exactly the foot-gun this module’s outputs are designed to help you automate around.

Module structure

terraform-module-azure-management-lock/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

variables.tf

variable "name" {
  description = "Name of the management lock. If null, a name is generated as lock-<lock_level lowercased>-<random suffix or derived>. Must be unique within the locked scope."
  type        = string
  default     = null

  validation {
    condition     = var.name == null || can(regex("^[A-Za-z0-9-._]{1,90}$", var.name))
    error_message = "name must be 1-90 characters and contain only letters, numbers, hyphens, underscores, or periods."
  }
}

variable "scope_id" {
  description = "ARM resource ID of the scope to lock. Accepts a subscription ID (/subscriptions/<id>), a resource group ID, or any individual resource ID. The lock is inherited by all child resources of this scope."
  type        = string

  validation {
    condition     = can(regex("^/subscriptions/[0-9a-fA-F-]+", var.scope_id))
    error_message = "scope_id must be a valid ARM resource ID beginning with /subscriptions/<subscription-id>."
  }
}

variable "lock_level" {
  description = "Lock level. CanNotDelete allows read/modify but blocks deletion. ReadOnly blocks all create/update/delete operations on the scope."
  type        = string
  default     = "CanNotDelete"

  validation {
    condition     = contains(["CanNotDelete", "ReadOnly"], var.lock_level)
    error_message = "lock_level must be exactly \"CanNotDelete\" or \"ReadOnly\" (case-sensitive)."
  }
}

variable "notes" {
  description = "Human-readable note explaining why the lock exists and who owns it. Strongly recommended for auditability; surfaced in the Azure portal."
  type        = string
  default     = null

  validation {
    condition     = var.notes == null || length(var.notes) <= 512
    error_message = "notes must be 512 characters or fewer."
  }
}

variable "name_prefix" {
  description = "Prefix used when generating a lock name (only used when var.name is null)."
  type        = string
  default     = "lock"
}

main.tf

locals {
  # Derive a deterministic, convention-driven name when one is not supplied:
  # e.g. "lock-cannotdelete" / "lock-readonly".
  generated_name = format("%s-%s", var.name_prefix, lower(var.lock_level))

  lock_name = coalesce(var.name, local.generated_name)
}

resource "azurerm_management_lock" "this" {
  name       = local.lock_name
  scope      = var.scope_id
  lock_level = var.lock_level
  notes      = var.notes
}

outputs.tf

output "id" {
  description = "The ARM resource ID of the management lock."
  value       = azurerm_management_lock.this.id
}

output "name" {
  description = "The name of the management lock as created in Azure."
  value       = azurerm_management_lock.this.name
}

output "lock_level" {
  description = "The applied lock level (CanNotDelete or ReadOnly)."
  value       = azurerm_management_lock.this.lock_level
}

output "scope_id" {
  description = "The ARM resource ID of the scope that this lock protects."
  value       = azurerm_management_lock.this.scope
}

How to use it

Lock the production resource group with a CanNotDelete lock, then reference the lock’s ID from a downstream resource (here, an Azure Monitor activity-log alert that fires if anyone deletes the lock — your early warning that someone is about to tear down protected infrastructure):

resource "azurerm_resource_group" "prod" {
  name     = "rg-payments-prod-weu"
  location = "westeurope"
}

module "management_lock" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-management-lock?ref=v1.0.0"

  name       = "lock-payments-prod-nodelete"
  scope_id   = azurerm_resource_group.prod.id
  lock_level = "CanNotDelete"
  notes      = "Protects the payments production RG. Removal requires CAB approval — owner: platform-team@kloudvin.io"
}

# Downstream: alert on deletion of the lock itself (the canary for a teardown attempt).
resource "azurerm_monitor_activity_log_alert" "lock_removed" {
  name                = "alert-prod-lock-removed"
  resource_group_name = azurerm_resource_group.prod.name
  location            = "global"
  scopes              = [module.management_lock.id]

  criteria {
    category       = "Administrative"
    operation_name = "Microsoft.Authorization/locks/delete"
    resource_id    = module.management_lock.id
  }

  action {
    action_group_id = azurerm_monitor_action_group.platform_oncall.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/management_lock/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-management-lock?ref=v1.0.0"
}

inputs = {
  scope_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/management_lock && 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
scope_id string n/a Yes ARM resource ID to lock (subscription, resource group, or individual resource). The lock is inherited by all children of this scope.
name string null No Lock name (1–90 chars, alphanumerics/-/_/.). When null, a name is generated from name_prefix + lock level. Must be unique within the scope.
lock_level string "CanNotDelete" No Either CanNotDelete or ReadOnly (case-sensitive).
notes string null No Audit note (≤512 chars) explaining why the lock exists and who owns it.
name_prefix string "lock" No Prefix used only when generating a name (var.name is null).

Outputs

Name Description
id The ARM resource ID of the management lock.
name The name of the management lock as created in Azure.
lock_level The applied lock level (CanNotDelete or ReadOnly).
scope_id The ARM resource ID of the scope this lock protects.

Enterprise scenario

A fintech runs an Azure landing zone where the connectivity and identity platform subscriptions host the hub VNet, ExpressRoute gateway, Private DNS zones, and the central Key Vault that every spoke depends on. The platform team calls this module from their landing-zone pipeline to place a subscription-scoped CanNotDelete lock on each platform subscription, with notes pointing at the CAB change-record process for removal. When a workload team later runs an over-eager decommission script that tries to delete the shared ExpressRoute gateway, ARM rejects the call outright, the activity-log alert pages the on-call engineer, and an outage across every spoke is averted — all without granting or revoking a single RBAC assignment.

Best practices

TerraformAzureManagement LockModuleIaC
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