TerraformXXIII · Policy as CodeProduction Terraform
Required Tags and Resource Naming
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
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.Prodandprodare 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:
- 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 declaresdefault_tags, or scope the override to environment-specific keys only. - 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 theaws_*_tagging_supportedlist, refreshed at policy-repo upgrade. - Value drift after rename. A project code changes from
PROJ-181toPROJ-184. The oldPROJ-181resources 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. - Tags dropped by client-side. A module wrapper
explicitly sets
tags = {}. The provider-level default is replaced. Mitigation: rule againsttags = {}and againsttags = nullin the same selector. - 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
Q1. Which tag key distinguishes an IaC-managed tag from a hand-edited one?
Q2. What is the production-default mechanism for AWS tag propagation in Terraform 1.9?
Q3. A resource that is created without an Owner tag is only a finance cost centre issue.
Q4. Environment value should be one of an enum to avoid drift in dashboards. Which is correct?
Q5. Which layers does a mature required-tags policy operate on? (Select all that apply.)
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?
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.