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:
CanNotDelete— authorized principals can still read and modify the resource, but nobody (not even an Owner) can delete it until the lock is removed.ReadOnly— the scope becomes effectively frozen; principals can read it but cannot create, update, or delete anything inside it. This is stricter than it looks:ReadOnlyon a storage account, for example, blocks listing keys (a POST under the covers), andReadOnlyon a resource group prevents any new resource from being deployed into it.
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:
- enforce a consistent naming convention (
lock-<scope>-<level>) so locks are auditable across hundreds of subscriptions; - require a human-readable
notesfield that explains why the lock exists and who owns it; - validate that the lock level is one of the two legal values before
applyrather than failing mid-deploy; - expose the lock
idandnameas outputs so downstream automation (or a “break-glass” removal pipeline) can find and target it.
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:
- Production blast-radius protection. Put a
CanNotDeletelock on the resource group that holds your production SQL servers, Key Vaults, and Storage accounts so a fat-fingeredaz group deleteor a runaway pipeline cannot wipe them. - Shared platform / hub resources. Hub VNets, ExpressRoute / VPN gateways, Private DNS zones, and the central Log Analytics workspace are referenced by dozens of teams; a
CanNotDeletelock stops one team’s cleanup script from breaking everyone. - Stateful data stores. Storage accounts with
prevent_destroylifecycle blocks are still deletable from the portal — aCanNotDeletelock closes that gap at the control plane. - Compliance freezes. Use
ReadOnlyto freeze a scope during an audit, an incident investigation, or a change-freeze window where nothing should move. - Landing-zone guard rails. Apply
CanNotDeleteat the subscription scope of platform subscriptions (identity, connectivity, management) so the foundation of your landing zone cannot be torn down.
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 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/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
- Default to
CanNotDelete; treatReadOnlyas a scalpel.ReadOnlyblocks key-listing, scaling, tagging, and — critically — Terraform’s own writes, so reserve it for deliberate freezes and remove it before the next apply rather than leaving it on managed scopes. - Lock at the resource-group level, not per-resource, by default. Locks inherit downward, so one RG-scoped
CanNotDeletelock protects every resource inside and stays correct as resources are added; reserve per-resource locks for surgical cases like a single Storage account in a shared RG. - Always populate
noteswith the why and the who. A lock with no note is a future incident — engineers will burn time figuring out whether it’s safe to remove. Record the owning team and the approval path (e.g. CAB record / runbook link). - Standardise lock names (
lock-<scope>-<level>) so they are greppable across subscriptions and so deletion alerts can match on a predictableoperation_nameofMicrosoft.Authorization/locks/delete. - Build a deliberate break-glass removal path. Emit
id/nameas outputs and drive lock removal through a separate, audited “unlock” pipeline; the principal removing the lock still needsMicrosoft.Authorization/locks/deletepermission (e.g. Owner or User Access Administrator), so keep that role tightly held. - Locks are free, but
ReadOnlyhas hidden operational cost. There is no Azure charge for management locks, yet a forgottenReadOnlylock can block autoscale and routine ops — audit existing locks periodically (az lock list) and prune stale ones.