Skip to main content
RunBook Academy

TerraformXXIII · Policy as CodeProduction Terraform

Required Tags and Resource Naming

Intermediate⏱ ~12 minbash

What you'll learn

  • Define the canonical set of mandatory tag keys and value patterns
  • Propagate tags through the provider default_tags mechanism and via modules
  • Write the policy rule that enforces mandatory tags at plan time
  • Recognise the cost of missing tags: billing, security, ownership

Prerequisites

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

A resource without tags is a resource the organisation cannot bill for, cannot audit, and cannot contact a team about. In a small shop with 12 resources the engineer in the seat knows each one’s owner. In a production estate with 4 000 resources across 30 workspaces, the engineer does not, and the company has lost the ability to attribute cost, close an incident, or prove compliance. Tags are the metadata that turns an unattributable cost centre into a known one.

The mandatory tag set

A minimal but defensible tag set:

Key           Purpose                    Example value
---------------------------------------------------------------------------
Owner        Team email or service ID   platform-eng@example.com
Environment  Lifecycle stage            prod | staging | dev
CostCentre   Finance cost code          CC-1042
Project      Project code               PROJ-181
ManagedBy    The IaC tool (always)      terraform

Five keys. The first four are content; the fifth is the audit trail that lets you distinguish a tag we applied from a tag someone typed by hand. The convention is to lowercase the keys and to require all five.

The values are constrained by the policy:

  • Owner — a domain the org owns (validated against the corporate directory), or a service catalog ID.
  • Environment — an enum: prod, staging, dev, sandbox. The lowercase constraint matters. Prod and prod are not the same tag; dashboards will split them.
  • CostCentre — CC- followed by four digits, anchored to the finance system.
  • Project — PROJ- followed by the project code from the project-management system. Projects have owners; the ownership is real.
  • ManagedBy — terraform. Always. The audit invariant.

Propagation through the provider

Most clouds offer a provider-level default_tags mechanism. On AWS, declared at the provider:

provider "aws" {
  region = "eu-west-2"

  default_tags {
    tags = {
      Owner        = "platform-eng@example.com"
      Environment  = "prod"
      CostCentre   = "CC-1042"
      Project      = "PROJ-181"
      ManagedBy    = "terraform"
    }
  }
}

The provider injects the tags on every supported resource at the API call. Resources that opt out via default_tags exclusion blocks are the explicit exceptions. The tag drift between IaC and reality is bounded by what the exclusion list allows.

On Google Cloud, default_labels on the google provider:

provider "google" {
  project = "my-project"
  region  = "europe-west2"

  default_labels = {
    owner        = "platform-eng"
    environment  = "prod"
    cost-centre  = "CC-1042"
    project      = "PROJ-181"
    managed-by   = "terraform"
  }
}

Azure is less coherent; most teams pass tags through a module wrapper rather than rely on a provider-level default.

Per-resource overrides

Per-resource tags are for the cases where the resource does not fit the defaults — a shared resource billed to two teams, or an exception that will expire with the project.

resource "aws_s3_bucket" "shared_artefacts" {
  bucket = "acme-shared-artefacts"

  tags = {
    Owner       = "platform-eng@example.com"
    Environment = "prod"
    CostCentre  = "CC-1042"
    Project     = "PROJ-181"
    ManagedBy   = "terraform"
    SharedWith  = "data-eng@example.com"
  }
}

The override is always the full tag set. A partial override silently drops the defaults — the resource ships with Owner but not Environment, which is worse than the default-only case because the partial tag is harder to spot.

The policy rule

The policy runs against the plan, not against the configuration. Three layers:

Layer 1: provider-level defaults are set. A static check on the configuration:

package terraform.provider_default_tags

deny[msg] {
  provider := input.provider_config.aws[_]
  not provider.default_tags
  msg := "AWS provider is missing default_tags; mandatory tags will not propagate"
}

Layer 2: taggable resources carry every key. At plan time:

package terraform.required_tags

required := {
  "Owner",
  "Environment",
  "CostCentre",
  "Project",
  "ManagedBy",
}

taggable := {
  "aws_instance",
  "aws_db_instance",
  "aws_s3_bucket",
  "aws_rds_cluster",
  "aws_iam_role",
  "aws_iam_user",
}

deny[msg] {
  resource := input.resource_changes[_]
  taggable[resource.type]
  missing := required - object.keys(resource.change.after.tags)
  count(missing) > 0
  msg := sprintf(
    "%s is missing tags %v",
    [resource.address, missing]
  )
}

Layer 3: values conform to the pattern. The shape check follows the key check. A resource that tags Environment = "Production" (capitalised) has the right key and the wrong value. The policy:

environment_values := {"prod", "staging", "dev", "sandbox"}

deny[msg] {
  resource := input.resource_changes[_]
  taggable[resource.type]
  resource.change.after.tags.Environment != "prod"
  resource.change.after.tags.Environment != "staging"
  resource.change.after.tags.Environment != "dev"
  resource.change.after.tags.Environment != "sandbox"
  msg := sprintf(
    "%s has invalid Environment value: %v",
    [resource.address, resource.change.after.tags.Environment]
  )
}

The three layers run as separate rules. Each rule has its tests. CI asserts that all three pass on the canonical infra-as-code repository.

The cost of missing tags

The four costs that materialise when tags are missing:

Cost                       Mechanism
-----------------------------------------------------------------------
Billing reconciliation     Finance cannot split the bill by team.
                           Untagged spend lands in shared overhead.
                           Last 30% of the bill is not attributable.

Security audit             An incident reviewer cannot identify
                           the owner of a resource exposed
                           during the breach. The remediation
                           stalls because there is no contact.

Ownership after reorg      A team splits or merges. Old tags
                           point to a Slack channel that
                           no longer exists. Nobody knows
                           who to page.

Chargeback disputes        A team is charged for a resource
                           that does not belong to them.
                           The dispute escalates because
                           the tag was never updated.

The remediations cost weeks per incident. The prevention costs one policy rule.

Validating the policy

Run the rule set end-to-end:

terraform plan -out plan.tfplan
terraform show -json plan.tfplan > plan.json
conftest test plan.json --policy policy/
PASS - plan.json - provider-default-tags
PASS - plan.json - required-tags.present
FAIL - plan.json - required-tags.values
       aws_instance.web has invalid Environment value: Production
3 tests, 2 passed, 1 failed

The failing rule points to the resource and the offending attribute. The engineer fixes the configuration; the next plan passes.

For a Terraform Cloud workspace, the same rule appears in the run output. The Sentinel variant:

sentinel test -verbose
PASS - required-tags-present.sentinel
PASS - required-tags-values.sentinel
2 policies, 4 tests, 0 failures

Failure modes of a tagging policy

Five failure modes to recognise:

  1. Default tags removed by environment override. An environment-specific configuration passes its own provider block without default_tags. The provider-level defaults are gone for that workspace. Test that the override provider block also declares default_tags, or scope the override to environment-specific keys only.
  2. Resource type not in selector. A new aws_* resource type is created. The selector enumerates a finite set; the new one is missed. Mitigation: union the selector with the aws_*_tagging_supported list, refreshed at policy-repo upgrade.
  3. Value drift after rename. A project code changes from PROJ-181 to PROJ-184. The old PROJ-181 resources remain. The policy is happy (the value still matches the pattern) but the CostCentre is wrong. Mitigation: the pattern check is only one of three. A second rule cross-checks the cost code against the finance API.
  4. Tags dropped by client-side. A module wrapper explicitly sets tags = {}. The provider-level default is replaced. Mitigation: rule against tags = {} and against tags = null in the same selector.
  5. Drift between config and reality. Someone tags a resource by hand in the console. The tag drifts. The plan says “no change” because the resource’s configuration is unchanged. Mitigation: a continuous scanning step (Trivy, AWS Config) that compares the resource’s actual tags with the policy. The Terraform-side policy and the cloud-side drift detector are two layers of the same control.

Production guidance

A few operational points:

  • Start with one workspace, one cloud, one resource type. Roll forward across the catalogue as confidence builds. Most policies launch in a single workspace and graduate to all workspaces over a quarter.
  • Run the policy on the rendered plan, not the HCL. A rule that scans the HCL will miss dynamic keys (variables, conditionals). Plan-time is the source of truth.
  • Audit quarterly. The cost codes, project codes, and required tag keys drift. Schedule a quarterly review of the rule, the values, and the exceptions.
  • Communicate the change. A policy that gets enabled without a heads-up generates an incident in Slack. A policy that gets enabled with one PR of KNOWN-EXCEPTION: annotations and a Slack post lands quietly.

What comes next

Tags are the visible metadata. Encryption is the next layer of policy: the controls that ensure state, plan files, and keys are not exposed during the apply.

Verification

terraform plan -out plan.tfplan
terraform show -json plan.tfplan > plan.json
conftest test plan.json --policy policy/tags.rego --output json
{
  "passed": 3,
  "failed": 0,
  "warnings": 0,
  "filename": "plan.json"
}

For the provider-level default check:

conftest test plan.json --policy policy/provider_default_tags.rego
PASS - plan.json - provider-default-tags
1 test, 1 passed

A passing run means every taggable resource in the plan carries every required key with a value that matches the pattern. The CI step fails the merge if any rule returns non-zero.

Knowledge check · 7 questions

  1. Q1. Which tag key distinguishes an IaC-managed tag from a hand-edited one?

  2. Q2. What is the production-default mechanism for AWS tag propagation in Terraform 1.9?

  3. Q3. A resource that is created without an Owner tag is only a finance cost centre issue.

  4. Q4. Environment value should be one of an enum to avoid drift in dashboards. Which is correct?

  5. Q5. Which layers does a mature required-tags policy operate on? (Select all that apply.)

  6. Q6. An engineer creates an aws_rds_cluster with all required tags, but the Environment value is 'Prod' with a capital P. The policy fails. What is the failure mode?

  7. Q7. A security incident review cannot determine the owner of a database instance because the instance has no tags. What is the most concrete operational consequence?

Passing score: 75%. Answers are checked in this browser.