Tagging cloud resources is one of those jobs that’s trivial to do badly and surprisingly fiddly to do well. Everyone agrees resources should be tagged. The argument nobody quite has out loud is where the tags should come from, and getting that wrong gives you either a giant copy-pasted tag block on every resource, or a set of tags that quietly disagree with each other across the account.
Tags answer two different questions
If you look at what tags are actually for, they split cleanly into two kinds, and the split is the whole point.
Some tags are true of every single resource in the account, identically. The environment it belongs to. The fact that OpenTofu manages it. The project or owner it rolls up to for cost reporting. These are invariants: a resource that didn’t carry them would be the bug.
Other tags are specific to a particular piece of infrastructure. Which component this resource belongs to, what subsystem it’s part of. The CloudTrail bucket is part of audit logging; the Config recorder is part of aws-config. That’s a fact about the module, not about the account.
Treat those two kinds the same and you end up repeating the invariants by hand on every resource, which is exactly the copy-paste that drifts. So the infra setup gives each kind its own home.
Layer one: declared once, on the provider
The invariants live on the AWS provider itself, as default_tags, set one time in the provider block:
provider "aws" {
# ...
default_tags {
tags = {
# Environment, project, managed-by: the things true of
# every resource in this account.
}
}
}
default_tags applies those tags to every taggable resource the provider creates, automatically, without a single resource having to mention them. Change the environment label once, here, and it propagates to everything on the next apply. No resource carries a copy; they all inherit the originals. The invariants are stated exactly once, in the one place that’s true for all of them.
Layer two: merged in by the module
The resource-specific tags live where the resource does: inside the module. Each module merges its own component tag over whatever tags it was handed, which you can see in the public terraform-aws-security-baseline modules:
tags = merge({ Component = "aws-config" }, var.tags)
So the aws-config module stamps Component = "aws-config" onto the things it builds, the account-hardening module stamps its own, and so on. The caller can pass extra tags down through var.tags, and because they come last in the merge, the caller can override the module’s defaults when it genuinely needs to. Module-specific knowledge stays in the module; per-call adjustments stay with the caller.
Which layer wins
Now the question that actually bites: a resource is getting tags from the provider’s default_tags and from the module’s merge. What happens when both set the same key?
The resource-level tags win. AWS’s provider treats tags set directly on a resource as an override of default_tags on a key collision, so the module’s merged tags take precedence over the account-wide defaults. That’s the right way round: the invariants are a sensible baseline, and a module that has a specific reason to set a key differently can, without having to reach up and edit the provider block that everything else depends on. Most of the time the two layers are simply disjoint, the invariants saying what account this is and the module tags saying what this resource is for, and they never collide at all. When they do, local intent beats the global default, which is the precedence you’d want.
Why bother splitting it
The payoff is that neither layer has to know about the other. The provider declares the invariants once and never thinks about components. Each module declares its component and never hard-codes the environment. Add a new module and it inherits every account-wide tag for free, while contributing its own. Change an account-wide tag and you touch one block, not two hundred resources. The tags stay consistent not because someone’s policing them, but because the place each tag is declared is the one place it can be declared.
The short version
Resource tags answer two questions, and they want two homes. Account-wide invariants (environment, ownership, managed-by) go on the provider’s default_tags, declared once and inherited by everything. Resource-specific tags go in the module, via merge({ Component = "..." }, var.tags), so each module owns its own labels and the caller can still override. On a key conflict the resource-level tag wins, which means the module’s intent beats the account default exactly when it should. Two layers, each declared in the one place it belongs, and no copy-pasted tag block anywhere in sight.
