Quick take — A reusable Terraform module for the Azure Resource Group — the foundational scope for every workload. What it is, the module files, how to consume it, its inputs/outputs, an enterprise landing-zone scenario, and the governance guardrails (mandatory tags, delete locks) that keep it production-ready. 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 "resource_group" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-resource-group?ref=v1.0.0"
# (no required inputs — all have sensible defaults)
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
The Resource Group (RG) is the fundamental deployment and lifecycle scope in Azure — every
resource lives in exactly one RG, and the RG is where you anchor tags, RBAC role
assignments, policy, locks, and cost roll-ups. Because everything starts with an RG,
it deserves a small, opinionated, reusable module rather than a copy-pasted resource block in
every stack.
This module wraps azurerm_resource_group with three things you always end up needing in
production:
- Enforced tagging — a
tagsmap merged with mandatory defaults (so cost/ownership tags are never forgotten). - An optional management lock —
CanNotDeleteorReadOnly, to stop accidental teardown of a shared environment. - Clean outputs — the RG
id,name, andlocationfor downstream modules to consume.
When to use it
- Per environment (
dev/test/prod) and per workload, as the first module in every stack. - As the unit a landing zone hands to an application team (one governed RG, pre-tagged and locked).
- Anywhere you want tags and locks applied consistently instead of per-engineer discretion.
Module structure
modules/azure-resource-group/
├── main.tf
├── variables.tf
├── outputs.tf
└── versions.tf
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
main.tf
locals {
# Mandatory tags merged with caller-supplied tags (caller wins on conflicts).
tags = merge(
{
managed_by = "terraform"
module = "azure-resource-group"
},
var.tags,
)
}
resource "azurerm_resource_group" "this" {
name = var.name
location = var.location
tags = local.tags
}
# Optional management lock to protect shared/prod resource groups.
resource "azurerm_management_lock" "this" {
count = var.lock_level == null ? 0 : 1
name = "${var.name}-lock"
scope = azurerm_resource_group.this.id
lock_level = var.lock_level
notes = "Managed by Terraform — module azure-resource-group"
}
variables.tf
variable "name" {
type = string
description = "Resource group name. Follow your naming convention, e.g. rg-<workload>-<env>-<region>."
validation {
condition = can(regex("^[a-zA-Z0-9._()-]{1,90}$", var.name))
error_message = "RG name must be 1-90 chars of letters, digits, and . _ ( ) -."
}
}
variable "location" {
type = string
description = "Azure region (e.g. centralindia, eastus)."
}
variable "tags" {
type = map(string)
description = "Tags merged onto the resource group (cost-center, owner, env, etc.)."
default = {}
}
variable "lock_level" {
type = string
description = "Optional management lock: 'CanNotDelete' or 'ReadOnly'. null = no lock."
default = null
validation {
condition = var.lock_level == null || contains(["CanNotDelete", "ReadOnly"], var.lock_level)
error_message = "lock_level must be null, 'CanNotDelete', or 'ReadOnly'."
}
}
outputs.tf
output "id" {
description = "Resource group ID — pass to downstream modules as their scope."
value = azurerm_resource_group.this.id
}
output "name" {
description = "Resource group name."
value = azurerm_resource_group.this.name
}
output "location" {
description = "Resource group location."
value = azurerm_resource_group.this.location
}
How to use it
Reference the module from a stack and feed its outputs into everything else:
module "rg" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//azure-resource-group?ref=v1.0.0"
name = "rg-payments-prod-cin"
location = "centralindia"
lock_level = "CanNotDelete" # protect prod from accidental deletion
tags = {
env = "prod"
workload = "payments"
cost_center = "FIN-204"
owner = "platform@kloudvin.com"
}
}
# Everything downstream consumes the RG's name + location.
resource "azurerm_storage_account" "sa" {
name = "stpaymentsprodcin"
resource_group_name = module.rg.name
location = module.rg.location
account_tier = "Standard"
account_replication_type = "ZRS"
tags = module.rg.tags # if you also output tags
}
Pin the module with
?ref=<tag>so a stack never silently picks up a breaking module change.
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/resource_group/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-resource-group?ref=v1.0.0"
}
inputs = {
# (no required inputs)
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/resource_group && 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 |
— | ✅ | Resource group name (validated, ≤ 90 chars). |
location |
string |
— | ✅ | Azure region. |
tags |
map(string) |
{} |
— | Tags merged with module defaults. |
lock_level |
string |
null |
— | CanNotDelete | ReadOnly | null. |
Outputs
| Name | Description |
|---|---|
id |
Resource group ID (use as scope for RBAC/policy/locks). |
name |
Resource group name. |
location |
Resource group location. |
Enterprise scenario
A platform team runs an enterprise-scale landing zone. When an app team is onboarded, a
subscription-vending pipeline calls this module in a for_each over the requested environments,
producing one pre-tagged, delete-locked RG per environment with the cost-center and owner stamped
on automatically. An Azure Policy assignment at the management-group scope then denies any
resource that lacks the cost_center tag — and because the module guarantees the tag at the RG level
and resources can inherit it, app teams never trip the deny rule. The result: hundreds of resource
groups across dozens of subscriptions, all governed identically, with zero per-team tagging drift.
Best practices
- Naming: adopt a convention (
rg-<workload>-<env>-<region>) and enforce it with thenamevalidation above (or a policy). - One workload per RG per environment — RGs are a lifecycle boundary; deleting one should be safe.
- Lock prod with
CanNotDelete; keepdevunlocked for fast iteration. - Tag at the RG and let resources inherit via policy (
modify/appendeffects) where possible. - Pin the module version and change it deliberately; treat the module like any other release.
Part of the KloudVin Terraform module library. Continue with the foundational governance modules:
Azure Policy (definitions & assignments) and Azure RBAC (role assignments) — they attach to
the id this module outputs.