IaC Azure

Terraform Module: Azure Resource Group — Reusable, Governed, Production-Ready

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:

When to use it

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


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.

TerraformAzureResource GroupModuleIaC
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