Tag Every Resource with a Tagging Strategy
Tags that are copy-pasted into every Terraform file drift apart, and renaming one means editing them all. This tutorial
proposes a tagging strategy, shows which tags to use, and implements it two ways: with the AWS provider's
default_tags and with the null label naming module. In both, you define each tag once, at the stack level where
the value is true, and every component inherits it.
You will learn
- Decide a tag convention first: a small set of tags, grouped by what they answer (what is it, who owns it, where did it come from)
- Set each tag at the level where its value is true: organization, product, or environment. Atmos deep-merges them down to every component
- Fill in the source tags (repository, branch, commit, stack, component) automatically with YAML functions and template variables
- Use
default_tagson AWS to tag every resource without touching Terraform code, or a naming module on any cloud to derive names and tags from one shared context - Validate the convention in the pull request so a missing tag fails the PR instead of reaching the cloud
The Problem
Tags often start as a block in a Terraform file:
tags = {
product = "checkout"
environment = "prod"
owner = "payments-team"
}
That block gets copied into every component. After a year there are about a hundred copies. Adding a tag, removing one,
or renaming owner to team means editing every file, and the copies are never all in sync.
The goal is to define each tag in one place and have every resource inherit it. Every resource should also say where it came from: which repository, branch, stack, and component, and which commit.
A Tagging Strategy
A convention is a short list of tag keys with agreed meanings. The one below is a starting point. Choose the actual tag keys and values for your organization.
| Tag | Group | Purpose | Example values |
|---|---|---|---|
product | functional | Product or shared platform | checkout / platform |
environment | functional | SDLC stage | dev / qa / uat / prod |
tier | functional | Governance grade | production / nonprod |
region | functional | Cloud region | us-east-2 |
owner | ownership | Team accountable for the resource | payments-team |
managed-by | source | Marks IaC-managed resources, so click-ops stands out | atmos |
repository | source | Repository that defines the resource | acme/infrastructure |
source-branch | source | Branch that was last applied | main |
source-commit | source | Commit that was last applied | Git commit SHA |
stack | source | Atmos stack that deployed the resource | checkout-prod |
component | source | Atmos component that manages the resource | vpc |
Optional for regulated workloads: data-classification (phi / confidential / internal). It makes sensitive
resources easy to find for audits and access reviews.
A few principles keep the convention healthy:
- Lowercase keys and values, with hyphens. One casing rule means one spelling per tag.
- One owner per resource. Use a team name or distribution list, not an individual.
- Source tags come from the system. Nobody types a commit SHA or a stack name, so those values cannot be wrong.
- Only tags the code can keep current. Cost center, expiry dates, and similar values change outside the repository and go stale. Add them when a process keeps them up to date.
Where Each Tag Is Set
Set each tag at the level where its value is true. The example in this tutorial has three levels, and each one imports the one above it.
| Level | File | Tags |
|---|---|---|
| Organization | orgs/acme/_defaults.yaml | managed-by, repository, source-branch, source-commit, stack, component, region |
| Tenant | orgs/acme/payments/_defaults.yaml | product, owner |
| Stage | orgs/acme/payments/dev.yaml | environment, tier |
The directories follow the usual Atmos layout: orgs/<organization>/<tenant>/<stage>.yaml, with a _defaults.yaml
at the organization and tenant levels. The stack name comes from name_template in atmos.yaml, so this stack is
payments-dev.
Changing a tag is then a one-line edit in one file, and the next plan shows the change on every affected resource.
Implement the Strategy
Both implementations below use the same three levels. Pick the tab that matches your cloud and modules.
For a minimal starting point that demonstrates only tag inheritance, try
examples/tagging-default-tags and
examples/tagging-null-label.
- default_tags (AWS)
- Null label (any cloud)
default_tags is a feature of the Terraform AWS provider. It adds a set of tags to every resource the provider creates
that supports the tags argument, without editing any resource block. Atmos
generates the provider configuration for every component from stack configuration, so the tags
are defined once instead of in every root module.
Start at the organization level. The source tags come from YAML functions and template variables, so no one maintains them by hand:
| Tag | Set with | Returns |
|---|---|---|
repository | !git.repository | Owner and name, for example acme/infrastructure |
source-branch | !git.branch | Current branch |
source-commit | !git.sha | Current commit SHA |
stack | {{ .atmos_stack }} | Stack being deployed |
component | {{ .atmos_component }} | Component being deployed |
The trailing word after each Git function, such as unknown, is a fallback for directories that are not Git
checkouts. Templating is enabled by default. If you turned it off with templates.settings.enabled: false in
atmos.yaml, Atmos writes the {{ ... }} text into the generated file unrendered.
The tenant level adds its own tags. It imports the organization defaults, and the two tags maps merge:
The stage level does the same:
Describe the component to see the merged result:
atmos describe component app --stack=payments-dev --query=.providers
aws:
default_tags:
tags:
component: app
environment: dev
managed-by: atmos
owner: payments-team
product: checkout
region: us-east-2
repository: acme/infrastructure
source-branch: main
source-commit: 0bed64ed1c7b95255d42bae36e1e5465a341bcab
stack: payments-dev
tier: nonprod
region: us-east-2
When you run atmos terraform plan or apply, Atmos writes this into providers_override.tf.json in the component
directory, and the AWS provider tags every resource. The Terraform code does not change.
!git.sha changes with every commit, and a plan from a feature branch reports a different !git.branch than main.
Either one makes every component's plan show a tag update, even when nothing else changed. For comparable local and
CI plans, prefer stable resource tags and keep commit/branch provenance in pipeline history. If you require those
tags, supply the same canonical values to both environments.
The optional CI override below does not make local and CI plans equivalent: a local plan can propose changing pipeline-written tags back to checkout values. Use it only if you deliberately accept that behavior.
A CI checkout is often shallow or detached, so the Git functions can return fallbacks there. In the pipeline, read the values from the environment variables it sets instead. Keep them in a small mixin:
Then import the mixin with a when condition, so it applies only in CI and
developers keep the Git functions on their workstations:
Alternatively, leave these two tags out and rely on pipeline history.
Two more behaviors are worth knowing:
- Tags that a module or resource sets for the same key win over
default_tags. Remove per-resource copies of these keys over time, so the convention has one source. - A small number of resource types ignore provider-level tags, such as
aws_autoscaling_group. Check the provider's resource tagging guide for the current list.
Other providers have their own mechanisms. The Google provider has a provider-level default_labels, and azurerm has
no provider-level equivalent. The null label tab describes a shared naming and tagging pattern that can be adapted
to each provider.
terraform-null-label is a naming module. Every component
passes a shared context (namespace, tenant, environment, stage, name, and a tags map) to the module, and the
module returns a consistent name (module.this.id) and a complete tag map (module.this.tags). The component applies
that map through its provider's resource tags or labels argument, taking into account the specific cloud, provider,
and resource type's limitations.
Check the permitted characters, casing, lengths, and number of tags or labels before applying this pattern.
Null Label preserves the tags you supply; it does not normalize them for each cloud. For example,
Google Cloud labels
cannot contain /, so repository: acme/infrastructure needs a provider-compatible representation such as
acme-infrastructure. Define a consistent normalization rule in shared component code, account for collisions
between normalized values, and validate the resulting keys and values against the target provider's limits.
Components get the module through a shared context.tf file that declares the context variables and instantiates the
module as module.this. The module is a child module that every component calls. It is never a component itself. In
stack configuration, the context is ordinary vars, so it inherits through the same imports as everything else.
The minimal Null Label example declares only namespace, stage, name, and tags. To extend it with the
tenant, label_key_case, and label_value_case inputs below, declare those variables in context.tf and pass
them to module.this as well.
Start at the organization level. The vars.tags map holds the source tags, which come from
YAML functions and template variables:
label_key_case: lower and label_value_case: lower set the case of the tags the module generates from the label
context. The module's default key case is title case. These settings do not change the keys or values you pass in
vars.tags, so write those in lowercase yourself, or check the casing with a validation policy as described in
Enforce the Convention.
The tenant level adds the tenant and the product tags:
The stage level adds the stage and the tier tag:
The module generates tags from the context itself: namespace, tenant, stage, and a name tag whose value is the
full generated ID. Those keys do not need to appear in vars.tags. The stage label holds the SDLC stage such as dev
or prod, so it covers the environment row of the convention.
Describe the component to see the merged variables:
atmos describe component app --stack=payments-dev --query=.vars
label_key_case: lower
label_value_case: lower
name: app
namespace: acme
region: us-east-2
stage: dev
tags:
component: app
managed-by: atmos
owner: payments-team
product: checkout
region: us-east-2
repository: acme/infrastructure
source-branch: main
source-commit: 0bed64ed1c7b95255d42bae36e1e5465a341bcab
stack: payments-dev
tier: nonprod
tenant: payments
The plan shows the supplied tags together with the tags the module generates from the label context. The generated
tags use the lowercase keys and values that label_key_case and label_value_case ask for:
atmos terraform plan app --stack=payments-dev
# terraform_data.this will be created
+ resource "terraform_data" "this" {
+ id = (known after apply)
+ input = {
+ component = "app"
+ managed-by = "atmos"
+ name = "acme-dev-app"
+ namespace = "acme"
+ owner = "payments-team"
+ product = "checkout"
+ region = "us-east-2"
+ repository = "acme/infrastructure"
+ source-branch = "main"
+ source-commit = "0bed64ed1c7b95255d42bae36e1e5465a341bcab"
+ stack = "payments-dev"
+ stage = "dev"
+ tenant = "payments"
+ tier = "nonprod"
}
+ output = (known after apply)
}
Changes to Outputs:
+ id = "acme-dev-app"
+ tags = {
+ component = "app"
+ managed-by = "atmos"
+ name = "acme-dev-app"
+ namespace = "acme"
+ owner = "payments-team"
+ product = "checkout"
+ region = "us-east-2"
+ repository = "acme/infrastructure"
+ source-branch = "main"
+ source-commit = "0bed64ed1c7b95255d42bae36e1e5465a341bcab"
+ stack = "payments-dev"
+ stage = "dev"
+ tenant = "payments"
+ tier = "nonprod"
}
The same plan-noise caveat applies here. !git.sha and !git.branch change on every commit or branch, so every plan
shows a tag update. Prefer stable tags and pipeline history. If commit or branch tags are required, use the same
canonical values locally and in CI; switching between pipeline values and the current checkout produces plan diffs.
A component can add tags of its own through Atmos component metadata. The
!labels function copies metadata.labels into
vars.tags, and the tags inherited from the organization, product, and environment levels merge into the result.
The Naming Module
The null label tab introduces a pattern that is useful beyond tagging: a naming module. A naming module owns one shared context, such as namespace, tenant, environment, stage, name, and attributes, and derives both resource names and resource tags from it.
With the context above, the module produces:
| Output | Value | Used for |
|---|---|---|
module.this.id | acme-dev-app | The name of every resource the component creates |
module.this.tags | The tag map | The tags argument on every resource |
Because names and tags come from the same inputs, they cannot drift apart. A resource named acme-dev-app
always carries stage = dev and tenant = payments.
Atmos supplies the context. The stack hierarchy already says which organization, tenant, environment, and stage a
component belongs to, so those values are set once in _defaults.yaml files and reach the module as vars. A component
author writes no naming logic.
The two approaches combine. Use the naming module for names and per-resource tags, and add default_tags on AWS as a
backstop for taggable resources that a component does not tag explicitly. default_tags only applies to resource types
that support provider-level tagging, so it does not cover everything.
Atmos Tags and Labels Are Different
Atmos components have their own metadata.tags and metadata.labels. metadata.tags is a list that selects components
with the --tags flag, such as atmos list components --tags=networking. Atmos uses them for selection and filtering.
To turn a metadata.labels map into cloud resource tags, pass it to a module's tags variable with
!labels. See component metadata for the details.
Enforce the Convention
A convention that nobody checks decays. Validate the inputs and enforce the convention where resources are created.
In the pull request, configure Atmos stack validation with OPA or JSON Schema to require
product, tier, owner, and the SDLC stage. For the AWS default-tags approach, validate environment in the
provider's tag map. For Null Label, validate vars.stage alongside product, tier, and owner in vars.tags;
the module generates the stage tag from that input. Run validation in CI so missing values fail the pull request.
On AWS, combine controls that serve different purposes:
- Require tags at creation. Use Organizations service control policies (SCPs) to deny supported create actions when required request tags are missing. Scope policies to actions that support tagging on creation and the relevant request-tag condition keys, and test service workflows before expanding enforcement. AWS provides an example combining SCPs and tag policies.
- Enforce allowed values and casing. Organizations tag policies can reject noncompliant supplied tags on supported resource types. Their basic compliance rules do not require missing tags to be present. See tag policy enforcement.
- Detect missing tags on existing resources. Configure AWS Config's
required-tagsrule for supported resource types, including resources created outside Atmos. This reports noncompliance after creation; it does not block creation.
AWS also supports required-tag validation for IaC using Organizations tag policies. For Terraform, this requires AWS provider 6.22.0 or later and explicitly enabling tag policy validation. This checks deployments through the configured tool; use SCPs for controls on supported API requests beyond that tool.
Roll It Out
- Start from clean plans. Adding tags makes every plan show tag changes, so do it when other pending changes are already applied.
- Add the tags in the organization defaults. Read each plan for replacements before you apply it. Tag changes are in-place updates for most resources, but a few resource types are replaced when their tags change.
- Remove the old tag blocks from the Terraform files as you touch each component.
Try It
The examples focus on tag inheritance with one shared configuration and two stacks. The AWS example describes
provider tags without connecting to AWS; the Null Label example plans a built-in terraform_data resource without
cloud credentials. The organizational hierarchy and source tags above are extensions you can add later.
Copy either example into a new directory with atmos init:
# AWS provider default_tags
atmos init examples/tagging-default-tags
cd tagging-default-tags
atmos describe component app --stack=dev --query=.providers
# Null Label naming module
atmos init examples/tagging-null-label
cd tagging-null-label
atmos describe component app --stack=dev --query=.vars
atmos terraform plan app --stack=dev
Related
- Generate Terraform Providers: the full
providersreference and merge behavior - Setting Default Tags:
default_tagsin the provider reference !labels: bridge component labels into a module'stagsvariable- Git YAML functions:
!git.repository,!git.branch,!git.sha