DEV Community

Cover image for Stop the Bleed: Enforce Mandatory Tagging on Azure with Terraform & Azure Policy
Suhas Mallesh
Suhas Mallesh

Posted on • Edited on

Stop the Bleed: Enforce Mandatory Tagging on Azure with Terraform & Azure Policy

Untagged Azure resources are silently draining your budget. Here's how to enforce mandatory tagging at scale using Terraform and Azure Policy — so every resource has an owner, a cost center, and a purpose.

Your Azure bill is $47,000/month. Which team owns 60% of it? Nobody knows. 🤷

Here's a scenario that plays out at almost every company running Azure at scale: someone spins up a Standard_D8s_v5 VM for a "quick load test." No tags. No cost center. No owner. Three months later, it's still running and nobody knows who created it — or if it's even needed.

Untagged resources are untraceable resources. And untraceable resources are the #1 reason cloud bills spiral out of control.

Before you can optimize a single dollar, you need to answer one question: Who owns what?

Tags are that answer. Let's enforce them. 🔒

🏷️ Why Tags Are Non-Negotiable

Without a consistent tagging strategy, your organization is flying blind:

❌ No tags = No cost allocation
❌ No tags = No ownership accountability  
❌ No tags = No automated cleanup
❌ No tags = No meaningful budget alerts
❌ No tags = FinOps is impossible
Enter fullscreen mode Exit fullscreen mode

Here's what a proper tagging standard looks like:

Tag Key Purpose Example
Environment Identifies deployment stage dev, staging, prod
CostCenter Maps to finance/billing CC-1042, engineering
Owner Who's responsible team-platform, john.doe
Project Links to business initiative project-phoenix
ManagedBy How it was created terraform, manual

The problem? Tags are optional by default in Azure. Anyone can create a resource group or resource without a single tag. That's where Azure Policy + Terraform comes in. 💪

🛡️ Step 1: Block Untagged Resource Groups (The Gatekeeper)

Resource groups are the top of the hierarchy. If you enforce tags here, you can inherit them downward to every resource inside. This is the most impactful policy you'll ever deploy.

# policies/require-tags/main.tf

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

provider "azurerm" {
  features {}
}

data "azurerm_subscription" "current" {}

# --- Define the mandatory tags your org requires ---
variable "mandatory_tags" {
  description = "Tags that must exist on every resource group"
  type        = list(string)
  default     = ["Environment", "CostCenter", "Owner", "Project"]
}

# --- Policy Definition: Deny resource groups missing required tags ---
resource "azurerm_policy_definition" "require_tag_on_rg" {
  for_each = toset(var.mandatory_tags)

  name         = "require-tag-${lower(each.value)}-on-rg"
  policy_type  = "Custom"
  mode         = "All"
  display_name = "Require '${each.value}' tag on Resource Groups"
  description  = "Denies creation of resource groups without the '${each.value}' tag."

  policy_rule = jsonencode({
    if = {
      allOf = [
        {
          field  = "type"
          equals = "Microsoft.Resources/subscriptions/resourceGroups"
        },
        {
          field  = "tags['${each.value}']"
          exists = "false"
        }
      ]
    }
    then = {
      effect = "deny"
    }
  })
}

# --- Assign each policy to the subscription ---
resource "azurerm_subscription_policy_assignment" "require_tag_on_rg" {
  for_each = toset(var.mandatory_tags)

  name                 = "require-${lower(each.value)}-rg"
  policy_definition_id = azurerm_policy_definition.require_tag_on_rg[each.value].id
  subscription_id      = data.azurerm_subscription.current.id
  display_name         = "Require '${each.value}' on Resource Groups"
  enforce              = true

  non_compliance_message {
    content = "⛔ Resource group must have the '${each.value}' tag. Add it and try again."
  }
}
Enter fullscreen mode Exit fullscreen mode

What happens now? Any attempt to create a resource group without Environment, CostCenter, Owner, or Project tags gets denied instantly. No exceptions. No "I'll add it later." 🚫

Try creating one without tags:

az group create --name "rg-mystery-project" --location "eastus"

# ❌ Error: Resource group 'rg-mystery-project' creation denied by policy.
# ⛔ Resource group must have the 'CostCenter' tag. Add it and try again.
Enter fullscreen mode Exit fullscreen mode

Beautiful. 😊

🧬 Step 2: Auto-Inherit Tags from Resource Group → Resources

Blocking untagged resource groups is step one. But what about the resources inside them? You don't want developers manually copying tags to every VM, disk, and NIC.

Azure has a built-in policy for this: "Inherit a tag from the resource group". Let's assign it with Terraform:

# policies/inherit-tags/main.tf

# --- Use Azure's built-in "Inherit a tag from the resource group" policy ---
data "azurerm_policy_definition" "inherit_tag_from_rg" {
  display_name = "Inherit a tag from the resource group"
}

# --- Assign inheritance for each mandatory tag ---
resource "azurerm_subscription_policy_assignment" "inherit_tag" {
  for_each = toset(var.mandatory_tags)

  name                 = "inherit-${lower(each.value)}-rg"
  policy_definition_id = data.azurerm_policy_definition.inherit_tag_from_rg.id
  subscription_id      = data.azurerm_subscription.current.id
  display_name         = "Inherit '${each.value}' from Resource Group"
  enforce              = true
  location             = "eastus" # Required for policies with managed identity

  parameters = jsonencode({
    tagName = {
      value = each.value
    }
  })

  identity {
    type = "SystemAssigned"
  }
}

# --- Grant the managed identity permission to tag resources ---
resource "azurerm_role_assignment" "tag_contributor" {
  for_each = toset(var.mandatory_tags)

  scope                = data.azurerm_subscription.current.id
  role_definition_name = "Tag Contributor"
  principal_id         = azurerm_subscription_policy_assignment.inherit_tag[each.value].identity[0].principal_id
}
Enter fullscreen mode Exit fullscreen mode

Now every new resource created inside a tagged resource group automatically inherits those tags. Zero manual effort. Zero drift. ✅

🧱 Step 3: Bundle It All Into a Policy Initiative (The Clean Way)

In production, you don't want 8+ individual policy assignments floating around. Group them into an Initiative (Policy Set) for cleaner management:

# policies/tag-governance-initiative/main.tf

resource "azurerm_policy_set_definition" "tag_governance" {
  name         = "tag-governance-initiative"
  policy_type  = "Custom"
  display_name = "Tag Governance Initiative"
  description  = "Enforces mandatory tags on resource groups and inherits them to child resources."

  # Require tags on resource groups
  dynamic "policy_definition_reference" {
    for_each = toset(var.mandatory_tags)
    content {
      policy_definition_id = azurerm_policy_definition.require_tag_on_rg[policy_definition_reference.value].id
      reference_id         = "Require-${policy_definition_reference.value}-RG"
    }
  }

  # Inherit tags to child resources
  dynamic "policy_definition_reference" {
    for_each = toset(var.mandatory_tags)
    content {
      policy_definition_id = data.azurerm_policy_definition.inherit_tag_from_rg.id
      reference_id         = "Inherit-${policy_definition_reference.value}-RG"
      parameter_values = jsonencode({
        tagName = { value = policy_definition_reference.value }
      })
    }
  }
}

# Single assignment instead of 8+ individual ones
resource "azurerm_subscription_policy_assignment" "tag_governance" {
  name                 = "tag-governance"
  policy_definition_id = azurerm_policy_set_definition.tag_governance.id
  subscription_id      = data.azurerm_subscription.current.id
  display_name         = "Tag Governance"
  enforce              = true
  location             = "eastus"

  identity {
    type = "SystemAssigned"
  }

  non_compliance_message {
    content = "⛔ This resource violates the organization's tagging policy."
  }
}
Enter fullscreen mode Exit fullscreen mode

One initiative. One assignment. All your tagging rules — managed as a single unit. 🎯

⚡ Step 4: Make Your Terraform Modules Tag-Aware

The policies above are the guardrails. But as an architect, you also want to make it easy for developers to do the right thing. Build tags into your shared Terraform modules:

# modules/tagged-resource-group/main.tf

variable "name" {
  type = string
}

variable "location" {
  type    = string
  default = "eastus"
}

variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be: dev, staging, or prod."
  }
}

variable "cost_center" {
  type = string
}

variable "owner" {
  type = string
}

variable "project" {
  type = string
}

variable "extra_tags" {
  type    = map(string)
  default = {}
}

locals {
  required_tags = {
    Environment = var.environment
    CostCenter  = var.cost_center
    Owner       = var.owner
    Project     = var.project
    ManagedBy   = "terraform"
    CreatedAt   = timestamp()
  }

  all_tags = merge(local.required_tags, var.extra_tags)
}

resource "azurerm_resource_group" "this" {
  name     = var.name
  location = var.location
  tags     = local.all_tags

  lifecycle {
    ignore_changes = [tags["CreatedAt"]]
  }
}

output "id" {
  value = azurerm_resource_group.this.id
}

output "name" {
  value = azurerm_resource_group.this.name
}

output "tags" {
  value = local.all_tags
}
Enter fullscreen mode Exit fullscreen mode

Usage — developers can't forget tags even if they try:

module "app_rg" {
  source      = "./modules/tagged-resource-group"
  name        = "rg-payments-api-dev"
  environment = "dev"
  cost_center = "CC-1042"
  owner       = "team-payments"
  project     = "project-phoenix"
}

# ✅ All 6 tags applied automatically
# ✅ Passes Azure Policy checks
# ✅ Shows up correctly in Cost Management reports
Enter fullscreen mode Exit fullscreen mode

⚡ Quick Audit: Run This Right Now

Before deploying policies, check how bad your current situation is:

# Find ALL resource groups with ZERO tags 😱
az group list --query "[?tags == null || tags == \`{}\`].{Name:name, Location:location}" --output table

# Find resource groups missing a specific tag
az group list --query "[?tags.CostCenter == null].{Name:name, Tags:tags}" --output table

# Count tagged vs untagged resource groups
echo "Total RGs: $(az group list --query 'length(@)' -o tsv)"
echo "Untagged:  $(az group list --query "[?tags == null || tags == \`{}\`] | length(@)" -o tsv)"
Enter fullscreen mode Exit fullscreen mode

If more than 20% of your resource groups are untagged — you have a cost visibility problem. 🚨

💡 Architect Pro Tips

  • Start with Audit mode, not Deny — Roll out policies with "effect": "audit" first. Give teams 2-4 weeks to get compliant, then flip to deny. This avoids blocking production deployments on day one.

  • Use not_scopes for exceptions — Some resource groups (like AKS-managed MC_* groups) auto-create without tags. Exclude them from policy scope instead of fighting the system.

  • Tag inheritance uses Modify effect — The built-in "Inherit a tag" policy requires a managed identity with Tag Contributor role. Don't forget the role assignment or remediation silently fails.

  • Remediate existing resources — Policies only apply to new deployments by default. Run a remediation task to backfill tags on existing resources that were created before the policy.

  • Standardize tag values, not just keys — Consider adding policies that restrict Environment to only dev, staging, prod — otherwise you'll get dev, Dev, DEV, development, test and your cost reports are useless.

📊 TL;DR

Action Impact Effort
Require tags on resource groups Full cost visibility 15 minutes
Auto-inherit tags to resources Zero tag drift 10 minutes
Bundle into a Policy Initiative Clean governance at scale 10 minutes
Tag-aware Terraform modules Developer guardrails 20 minutes
Audit existing untagged resources Know your current gap 5 minutes

Bottom line: Every dollar of Azure spend should be traceable to a team, a project, and an environment. Tags make that possible. Azure Policy makes it mandatory. Terraform makes it repeatable. Deploy this today — your future self doing cost reviews will thank you. 🙌


Run that audit command above. I bet you'll find at least 10 untagged resource groups. Go on, I'll wait. 😏

This is Part 1 of the "Save on Azure with Terraform" series. Next up: Budget Alerts as Code — deploy Azure Cost Management budgets and alerts with Terraform so you catch overspend before it happens. 💬

Top comments (0)