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
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."
}
}
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.
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
}
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."
}
}
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
}
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
⚡ 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)"
If more than 20% of your resource groups are untagged — you have a cost visibility problem. 🚨
💡 Architect Pro Tips
Start with
Auditmode, notDeny— Roll out policies with"effect": "audit"first. Give teams 2-4 weeks to get compliant, then flip todeny. This avoids blocking production deployments on day one.Use
not_scopesfor exceptions — Some resource groups (like AKS-managedMC_*groups) auto-create without tags. Exclude them from policy scope instead of fighting the system.Tag inheritance uses
Modifyeffect — The built-in "Inherit a tag" policy requires a managed identity withTag Contributorrole. 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
Environmentto onlydev,staging,prod— otherwise you'll getdev,Dev,DEV,development,testand 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)