Skip to main content

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_tags on 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.

TagGroupPurposeExample values
productfunctionalProduct or shared platformcheckout / platform
environmentfunctionalSDLC stagedev / qa / uat / prod
tierfunctionalGovernance gradeproduction / nonprod
regionfunctionalCloud regionus-east-2
ownerownershipTeam accountable for the resourcepayments-team
managed-bysourceMarks IaC-managed resources, so click-ops stands outatmos
repositorysourceRepository that defines the resourceacme/infrastructure
source-branchsourceBranch that was last appliedmain
source-commitsourceCommit that was last appliedGit commit SHA
stacksourceAtmos stack that deployed the resourcecheckout-prod
componentsourceAtmos component that manages the resourcevpc

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.

LevelFileTags
Organizationorgs/acme/_defaults.yamlmanaged-by, repository, source-branch, source-commit, stack, component, region
Tenantorgs/acme/payments/_defaults.yamlproduct, owner
Stageorgs/acme/payments/dev.yamlenvironment, 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 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:

stacks/orgs/acme/_defaults.yaml
# Organization level: tags every stack in the organization receives.
vars:
namespace: acme
region: us-east-2

terraform:
providers:
aws:
region: "{{ .vars.region }}"
default_tags:
tags:
managed-by: atmos
# The trailing literals are fallbacks for directories that are not Git checkouts.
repository: !git.repository unknown
# Also falls back when HEAD is detached, which is typical in CI checkouts.
source-branch: !git.branch detached
source-commit: !git.sha unknown
stack: "{{ .atmos_stack }}"
component: "{{ .atmos_component }}"
# Derived from vars.region, so it cannot disagree with the provider region.
region: "{{ .vars.region }}"
TagSet withReturns
repository!git.repositoryOwner and name, for example acme/infrastructure
source-branch!git.branchCurrent branch
source-commit!git.shaCurrent 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:

stacks/orgs/acme/payments/_defaults.yaml
# Tenant level: adds the product and its owner to the organization tags.
import:
- orgs/acme/_defaults

vars:
tenant: payments

terraform:
providers:
aws:
default_tags:
tags:
product: checkout
owner: payments-team

The stage level does the same:

stacks/orgs/acme/payments/dev.yaml
# Stage level: the stack for payments in dev. It adds the stage tags to the tenant tags.
import:
- orgs/acme/payments/_defaults
- catalog/app/defaults

vars:
stage: dev

terraform:
providers:
aws:
default_tags:
tags:
environment: dev
tier: nonprod

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.

Source tags change between runs

!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:

stacks/mixins/ci-source-tags.yaml
# Pipeline-only variant of the source tags. The values come from environment variables that
# GitHub Actions sets, so they do not depend on the checkout state. The second argument
# is the default used when the variable is not set.
terraform:
providers:
aws:
default_tags:
tags:
source-branch: !env GITHUB_REF_NAME local
source-commit: !env GITHUB_SHA local

Then import the mixin with a when condition, so it applies only in CI and developers keep the Git functions on their workstations:

stacks/orgs/acme/payments/dev.yaml
import:
- orgs/acme/payments/_defaults
- catalog/app/defaults
# In CI, take the source tags from the pipeline environment instead of the checkout.
- path: mixins/ci-source-tags
when: ci

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.
default_tags is specific to AWS

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.

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:

OutputValueUsed for
module.this.idacme-dev-appThe name of every resource the component creates
module.this.tagsThe tag mapThe 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-tags rule 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​

  1. Start from clean plans. Adding tags makes every plan show tag changes, so do it when other pending changes are already applied.
  2. 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.
  3. 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