FinOps Cost Governance at Scale with Azure Policy & Terraform

Learning that cost control isn’t just a finance thing, it’s a DevOps responsibility. Using Terraform and Azure Policy, I automated governance so no resource slips through without a tag. It felt like I was bringing real discipline into the cloud just like CI/CD, but for cost control.

What Problem Are We Solving?

Imagine you’re working at a company where multiple teams deploy resources to Azure. Without proper governance:

  • Resources get created without proper tags
  • No one knows which department to charge for costs
  • Orphaned resources pile up
  • Cloud bills explode

Prerequisites

Before you start, make sure you have:

  • Azure subscription
  • Terraform installed
  • Azure CLI installed and authenticated
  • Basic Terraform knowledge
  • Azure DevOps account

Project Structure

Let’s organize our project properly:

finops-governance/
├── main.tf                 # Main Terraform configuration
├── variables.tf            # Input variables
├── outputs.tf              # Output values
├── provider.tf             # Azure provider setup
├── require-tags.tf     # Tag enforcement policy
└── deny-expensive-vms.tf  # Cost control policy
└── azure-pipelines.yml     # CI/CD pipeline (optional)

Step-by-Step Implementation

Step 1: Set Up Azure Provider

Create provider.tf:

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

provider "azurerm" {
  features {}
}

# Get current subscription info
data "azurerm_subscription" "primary" {
}

What this does: Configures Terraform to talk to Azure and fetches your subscription details.

Step 2: Define Variables

Create variables.tf:

variable "required_tags" {
  description = "Tags that must be present on all resources"
  type        = list(string)
  default     = ["CostCenter", "Environment", "Owner"]
}

variable "allowed_vm_sizes" {
  description = "List of allowed VM sizes to control costs"
  type        = list(string)
  default     = ["Standard_B1s", "Standard_B2s", "Standard_D2s_v3"]
}

Why variables? Makes your configuration reusable and easier to maintain.

Step 3: Create Tag Enforcement Policy

Create require-tags.tf:

resource "azurerm_policy_definition" "require_cost_tags" {
  # Unique name (no spaces, lowercase + hyphens)
  name         = "require-cost-tags-on-vms"
  
  # Type: Custom (we created it) vs BuiltIn (Microsoft provides it)
  policy_type  = "Custom"
  
  # Mode: Indexed means it applies to resources with tags
  mode         = "Indexed"
  
  # Human-readable name (shows in Azure Portal)
  display_name = "Require Cost Tracking Tags on VMs"
  
  # Description explaining why this policy exists
  description  = "Ensures all virtual machines have CostCenter, Environment, and Owner tags for financial tracking"

  # Metadata for organizing policies in Azure Portal
  metadata = jsonencode({
    category = "FinOps"
    version  = "1.0.0"
  })

  # ===================================
  # THE ACTUAL RULE (in JSON format)
  # ===================================
  policy_rule = jsonencode({
    # IF this condition is true...
    if = {
      allOf = [
        # Check 1: Is this a Virtual Machine?
        {
          field  = "type"
          equals = "Microsoft.Compute/virtualMachines"
        },
        # Check 2: Is ANY of these tags missing?
        {
          anyOf = [
            {
              field  = "tags['CostCenter']"
              exists = "false"
            },
            {
              field  = "tags['Environment']"
              exists = "false"
            },
            {
              field  = "tags['Owner']"
              exists = "false"
            }
          ]
        }
      ]
    },
    # THEN do this...
    then = {
      effect = "deny"  # Block the resource creation
    }
  })
}

What happens here?

  1. We create a custom policy that checks if VMs have required tags
  2. If tags are missing → Azure denies the resource creation
  3. The policy is assigned at the subscription level (applies everywhere)

Let’s understand the logic:

IF:
  - Resource type is "Virtual Machine"
  AND
  - (CostCenter tag is missing
     OR Environment tag is missing
     OR Owner tag is missing)
THEN:
  - DENY the creation

Real-world example:

  • Sarah tries to create a VM with only Environment=Dev tag
  • Policy checks: Missing CostCenter, Missing Owner
  • Azure says: “Access denied by policy ‘Require Cost Tracking Tags on VMs’”
Policy Assignment: Activating the Rule

Now we need to assign this policy to your subscription (turn it on).

Add this to the same file:

# ===================================
# POLICY ASSIGNMENT (SUBSCRIPTION LEVEL)
# ===================================
# This ACTIVATES the policy at the subscription level

resource "azurerm_subscription_policy_assignment" "enforce_cost_tags" {
  # Unique name for this assignment
  name         = "enforce-cost-tags"
  
  # Display name in Azure Portal
  display_name = "Enforce Cost Tags on All VMs"
  
  # Description of why this policy exists
  description = "Ensures all VMs have required tags for cost tracking and accountability"
  
  # Link to the policy definition we created above
  policy_definition_id = azurerm_policy_definition.require_cost_tags.id
  
  # Subscription ID: WHERE does this apply?
  subscription_id = data.azurerm_subscription.primary.id
  
  # Enforce: true = block violations, false = just log warnings (audit mode)
  enforce = true

  # Additional metadata
  metadata = jsonencode({
    assignedBy = "DevOps Team"
    reason     = "Cost governance and accountability"
    version    = "1.0.0"
  })
}

Why use azurerm_subscription_policy_assignment?

  • Modern approach: This is the current recommended resource
  • Explicit scope: Makes it clear you’re assigning at subscription level
  • Better organized: Azure now has specific resources for different scopes

Step 4: Create Cost Control Policy

Create deny-expensive-vms.tf:

# ===================================
# POLICY: Block Expensive VM Sizes
# ===================================

resource "azurerm_policy_definition" "deny_expensive_vms" {
  name         = "deny-expensive-vm-sizes"
  policy_type  = "Custom"
  mode         = "Indexed"
  display_name = "Deny Expensive VM Sizes"
  description  = "Only allows specific cost-effective VM sizes"

  metadata = jsonencode({
    category = "FinOps"
    version  = "1.0.0"
  })

  # The rule with allowed VM sizes
  policy_rule = jsonencode({
    if = {
      allOf = [
        # Check: Is this a VM?
        {
          field  = "type"
          equals = "Microsoft.Compute/virtualMachines"
        },
        # Check: Is the VM size NOT in our approved list?
        {
          not = {
            field = "Microsoft.Compute/virtualMachines/sku.name"
            in = [
              "Standard_B1s",    # ~$10/month
              "Standard_B2s",    # ~$40/month
              "Standard_D2s_v3"  # ~$100/month
            ]
          }
        }
      ]
    },
    then = {
      effect = "deny"
    }
  })
}

# Assign this policy to subscription
resource "azurerm_subscription_policy_assignment" "enforce_vm_sizes" {
  name                 = "enforce-approved-vm-sizes"
  display_name         = "Only Allow Approved VM Sizes"
  description          = "Restricts VM creation to cost-effective sizes only"
  policy_definition_id = azurerm_policy_definition.deny_expensive_vms.id
  subscription_id      = data.azurerm_subscription.primary.id
  enforce              = true
  
  metadata = jsonencode({
    assignedBy = "DevOps Team"
    category   = "Cost Control"
  })
}

Why this matters: Prevents developers from accidentally spinning up expensive VMs.

What this does:

  • Defines a list of “approved” VM sizes (cheap ones)
  • Blocks any VM creation that uses a different size

Real-world prices (approximate):

  • Standard_B1s: $10/month (good for testing)
  • Standard_D2s_v3: $100/month (good for dev)
  • Standard_D16s_v3: $800/month (expensive! We’ll block this)

Step 5: Create Outputs

Create outputs.tf:

output "policy_require_tags_id" {
  description = "The ID of the 'Require Cost Tags' policy definition"
  value       = azurerm_policy_definition.require_cost_tags.id
}

output "policy_require_tags_name" {
  description = "The name of the 'Require Cost Tags' policy definition"
  value       = azurerm_policy_definition.require_cost_tags.name
}

output "policy_deny_expensive_vms_id" {
  description = "The ID of the 'Deny Expensive VMs' policy definition"
  value       = azurerm_policy_definition.deny_expensive_vms.id
}

output "policy_deny_expensive_vms_name" {
  description = "The name of the 'Deny Expensive VMs' policy definition"
  value       = azurerm_policy_definition.deny_expensive_vms.name
}

Deploying the Policies

Manual Deployment

# 1. Initialize Terraform
terraform init

# 2. See what will be created
terraform plan

# 3. Apply the configuration
terraform apply -auto-approve

# 4. Verify policies are created
az policy assignment list --query "[].{Name:name, DisplayName:displayName}"

Step 7: Test the Policies

Test 1: Try creating a VM without tags
az vm create --resource-group rg-finops-governance-test --name test-vm-no-tags --image Ubuntu2204 --size Standard_B1s --admin-username azureuser --generate-ssh-keys

Expected result: Azure denies the request with a policy violation error!

Test 2: Create a VM with proper tags
az vm create \
  --resource-group rg-finops-governance-test \
  --name test-vm-with-tags \
  --image Ubuntu2204 \
  --size Standard_B1s \
  --admin-username azureuser \
  --generate-ssh-keys \
  --tags CostCenter=IT-DevOps Environment=Development Owner=YourName

Expected result: VM is created successfully!

Test 3: Try creating an expensive VM
az vm create \
  --resource-group rg-finops-governance-test \
  --name expensive-vm \
  --image Ubuntu2204 \
  --size Standard_D16s_v3 \
  --admin-username azureuser \
  --generate-ssh-keys \
  --tags CostCenter=IT-DevOps Environment=Development Owner=YourName

Expected result: Denied because VM size is not in allowed list!

Step 8: Automate with Azure DevOps

Note: Ensure your serrvice principal has the Resource-Policy persmission, you can use the command below

az role assignment create \
--assignee <Client-ID> \
--role "Resource Policy Contributor" \
--scope /subscriptions/<Subscription-ID>

Create azure-pipelines.yml:

trigger:
  branches:
    include:
      - main
  paths:
    include:
      - '**/*.tf'

pool:
  vmImage: 'ubuntu-latest'

variables:
  terraformVersion: '1.6.0'

stages:
  - stage: Validate
    jobs:
      - job: TerraformValidate
        steps:
          - task: TerraformInstaller@0
            inputs:
              terraformVersion: $(terraformVersion)
          
          - task: TerraformTaskV4@4
            displayName: 'Terraform Init'
            inputs:
              provider: 'azurerm'
              command: 'init'
              backendServiceArm: 'Azure-Service-Connection'
          
          - task: TerraformTaskV4@4
            displayName: 'Terraform Validate'
            inputs:
              provider: 'azurerm'
              command: 'validate'

  - stage: Plan
    dependsOn: Validate
    jobs:
      - job: TerraformPlan
        steps:
          - task: TerraformTaskV4@4
            displayName: 'Terraform Plan'
            inputs:
              provider: 'azurerm'
              command: 'plan'
              environmentServiceNameAzureRM: 'Azure-Service-Connection'

  - stage: Apply
    dependsOn: Plan
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: TerraformApply
        environment: 'Production'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: TerraformTaskV4@4
                  displayName: 'Terraform Apply'
                  inputs:
                    provider: 'azurerm'
                    command: 'apply'
                    commandOptions: '-auto-approve'
                    environmentServiceNameAzureRM: 'Azure-Service-Connection'

Conclusion

In this project, we’ve automated cloud governance like a true DevOps engineer. Instead of manually chasing down untagged resources and surprise bills, we’ve built a system that:

Prevents problems before they happen – No more $15,000 surprise bills
Enforces standards automatically – Every resource must follow the rules
Tracks costs accurately – Finance can see exactly who spent what
Scales effortlessly – Works for 10 resources or 10,000

The DevOps Mindset:

This project isn’t just about Azure Policy or Terraform it’s about bringing automation, accountability, and visibility to cloud costs. You’ve learned that:

  • Policy as Code is just as important as Infrastructure as Code
  • Prevention > Detection – Stop bad resources at creation, not cleanup later
  • DevOps owns costs – It’s not just a finance problem

Leave a comment

I’m Adedeji

I am a Microsoft MVP. Welcome to my blog. On this blog, I will be sharing my knowledge, experience and career journey. I hope you enjoy.

Let’s connect