If you work with AWS at scale, you know this problem even if you do not call it by its name. Someone defines a tagging standard in a wiki, in a Confluence page, in a PDF that circulates over Slack. And resources keep showing up without Proyecto, without CentroDeCosto, without Environment. Not out of bad faith, but because the standard lives in a document and the infrastructure lives in code, and between the two there is nothing forcing them to match.

The cost of that gap is not theoretical. Without consistent tags, cost reports stop adding up, attribute-based access control (ABAC) cannot apply permissions correctly, and automation (backups, patching, incident response) simply skips the resources it cannot identify. The missing tag is not noticed the day you forget it. It is noticed three months later, when you try to explain an invoice or isolate a compromised resource and discover you do not know whose it is.

The underlying question was always the same: at what point is the untagged resource detected? And for a long time the answer was “later.” Later, after the apply, with AWS Config flagging the resource as non-compliant. Later, with a script that walks the account and sends a report. Later, with a remediation policy that runs once the damage is done. Detecting “later” means the non-compliant resource already exists, already cost money, already broke the report.

Since version 6.22 of the Terraform AWS provider there is a different answer: before. Before the resource is created, during the plan.

TLDR

The AWS provider 6.22+ can validate your resources against your organization’s tag policies during terraform plan and terraform apply. If an organizational tag policy requires the tag Proyecto on a resource and your configuration does not have it, the plan fails with an error before touching anything in AWS. You enable it with one line in the provider block:

provider "aws" {
  tag_policy_compliance = "error"
}

It is opt-in (it does not affect existing configurations until you turn it on) and it moves tagging validation from “after the apply” to “during the plan,” which is where the error costs seconds instead of an audit.

The two pieces

This solution brings together two things many people use separately, or do not even know about: AWS Organizations tag policies and the idea of shift-left. The value is in how they combine, so it is worth understanding each one with its implementation alongside.

Piece 1: Organization Tag Policies (what is required)

If you manage several AWS accounts under an organization, AWS Organizations lets you define rules centrally and apply them downward. One of those rules is the tag policy: you declare that certain tags are required for certain resource types, you apply it to an account or to an entire OU, and from then on AWS knows which tags are required for those resources. The standard stops being a document and becomes a policy that lives in the organization.

The tag policy is defined as an aws_organizations_policy resource of type TAG_POLICY. The key piece is report_required_tag_for, where you declare for which resource types that tag is required. The following example requires the tag Proyecto on S3 buckets:

resource "aws_organizations_policy" "example" {
  name = "tag-policy-example"
  content = jsonencode({
    "tags" : {
      "Proyecto" : {
        "tag_key" : {
          "@@assign" : "Proyecto"
        },
        "report_required_tag_for" : {
          "@@assign" : [
            "s3:bucket"
          ]
        }
      }
    }
  })
  type = "TAG_POLICY"
}

Then you attach the policy to whatever target you want, for example the root of your organization:

data "aws_organizations_organization" "current" {}

resource "aws_organizations_policy_attachment" "example" {
  policy_id = aws_organizations_policy.example.id
  target_id = data.aws_organizations_organization.current.roots[0].id
}

To apply it to a single account, you use its ID in target_id:

resource "aws_organizations_policy_attachment" "example" {
  policy_id = aws_organizations_policy.example.id
  target_id = "123456789012" # ID of the target account
}

And for any other resource

The example uses S3, but the mechanism is the same for everything. The piece that defines the scope is report_required_tag_for, where you list the resource types for which that tag is required, using the service:resource-type format on the AWS side. s3:bucket maps to Terraform’s aws_s3_bucket resource, just as logs:log-group maps to aws_cloudwatch_log_group, dynamodb:table to aws_dynamodb_table, lambda:function to aws_lambda_function, and so on. To require the tag on several resource types, you list them all in the same @@assign:

"report_required_tag_for" : {
  "@@assign" : [
    "s3:bucket",
    "dynamodb:table",
    "lambda:function"
  ]
}

If you do not want to enumerate resource by resource, AWS supports the ALL_SUPPORTED wildcard per service: s3:ALL_SUPPORTED, ec2:ALL_SUPPORTED, rds:ALL_SUPPORTED cover every supported resource type of that service in a single line. What does not exist is a global “all resources in the account” wildcard: the scope is defined per service, and it only applies to the types that support this capability, not to absolutely everything that exists in AWS. What does cover your whole organization is the other side of the equation, the attachment’s target_id: by pointing it at the root or an OU, the same policy flows down to every account you have below it.

And watch out for one detail: without report_required_tag_for, the tag policy only standardizes the key name or the allowed values, but it does not require the tag to be present. It is that field, and not the tag policy in general, that makes the tag required and that ListRequiredTags (and therefore the provider) can see.

The complete correspondence between tag policy identifiers and Terraform resources is in the official Tag Policy Compliance guide. And if you need more than one required tag, for example Proyecto and CentroDeCosto, each one is another block inside tags, with its own set of resource types.

And if you create the resource without the tag?

Here comes the detail many people assume backwards. With the tag policy in place, you define this bucket without the Proyecto tag and run terraform apply:

resource "aws_s3_bucket" "example" {
}
aws_s3_bucket.example: Creating...
aws_s3_bucket.example: Creation complete after 2s [id=required-tags-demo]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

The bucket was created. Without the tag. The tag policy, on its own, did not prevent anything. The “required tag key” capability declares which tags are required and exposes them so that something can validate them, but it does not reject the operation at the API level. The non-compliant resource shows up only later, flagged in the tag policy’s compliance report, or when AWS Config or an audit script walks the account and finds it.

In other words: the first piece defines the standard, but on its own it returns you to the “later” we talked about in the intro. For the standard to be enforced before the resource is created, the second piece is missing.

Piece 2: shift-left (when you find out)

Shift-left is a simple idea: move the check as early as possible in the workflow. Instead of discovering the problem in production, you discover it in the code, before applying anything. It is the same logic as running the tests in the PR rather than on release day.

Applied to tagging, shift-left means the tag policy validation does not wait for the apply. It runs in the plan, which is what your CI executes when someone opens a PR. So the resource without the required tag makes the plan fail, the PR check turns red, and with branch protection that PR cannot be merged until it is fixed. The problem is caught in review, before touching the main branch and long before release. You found out in the plan, when fixing it costs adding one line and not opening a ticket.

This is what version 6.22 of the AWS provider enabled. The same policy as before, the same bucket without tags, but now you enable the validation with a single line in the provider block:

provider "aws" {
  tag_policy_compliance = "error"
}

With that turned on, that bucket without Proyecto no longer reaches the apply. When you run terraform plan against an account inside the target, the plan ends in an error:

Error: Missing Required Tags - An organizational tag policy requires the following tags for aws_s3_bucket: [Proyecto]

  with aws_s3_bucket.example,
  on main.tf line 23, in resource "aws_s3_bucket" "example":
  23: resource "aws_s3_bucket" "example" {

The error tells you the affected resource type and which tag is missing. You resolve it by adding the tag, either in the resource’s tags argument or in the provider’s default_tags (which is the usual way to apply tags to every resource in a configuration).

Compare this result with Piece 1. Before, the same bucket without a tag returned an “Apply complete!” and the problem appeared later, in a report. Now the plan ends in an error and never reaches the apply. Same policy, same resource, the only difference is one line in the provider, and the non-compliant resource stops existing entirely.

That argument accepts three values: error stops the plan, warning warns you but lets it through, and disabled turns it off. And it does not have to live in the code: the environment variable TF_AWS_TAG_POLICY_COMPLIANCE does exactly the same thing. That lets you enable or disable the validation without touching the configuration. If you set the environment variable and the provider argument at the same time, the provider argument takes precedence.

How it works under the hood (and why it matters)

It is worth understanding three details before turning it on in production.

You need the ListRequiredTags permission. The principal running Terraform must be able to call the ListRequiredTags API, which is where the provider gets the currently required tags. Without that permission the validation cannot run.

It does not validate everything, it validates what changes. Required-tag validation runs during create operations, and during update operations only when the tags change. Modifying an existing resource without touching its tags does not trigger the validation. It is a deliberate decision: it does not block an unrelated change just because a pre-existing resource ended up out of compliance.

It runs even with -refresh=false. The validation is implemented in the provider’s “interceptor” layer (a CustomizeDiff for SDK V2-based resources, a ModifyPlan for Plugin Framework ones), and those interceptors run even if you pass -refresh=false to the plan or the apply. The validation happens even if the remote resource is not refreshed beforehand.

How you would turn it on without ruining anyone’s day

Turning on error all at once in an organization with existing configurations is the fastest way for the team to disable the feature instead of fixing the tags. A better path, if you are just introducing tag policies: start with warning, let the teams discover which flows do not comply, and only then move up to error. That gives each team room to get organized before the plan starts blocking.

What really changes

What I find valuable about this feature is not the configuration line. It is that it closes a gap we have carried for years: the distance between the moment a rule is defined and the moment someone complies with it without accidentally breaking it. For a long time “tagging well” was an intention that depended on each person’s discipline and was verified late, when fixing it already cost a ticket or an invoice.

Now the rule and the verification live in the same place where the infrastructure lives, the code, and they are checked before applying. One line in the provider, and the plan takes care of the rest.