# 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.

> **Key points**
>
> - 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:

```hcl
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`](https://github.com/cloudposse/atmos/tree/main/examples/tagging-default-tags) and
[`examples/tagging-null-label`](https://github.com/cloudposse/atmos/tree/main/examples/tagging-null-label).

### default\_tags (AWS)

`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](/stacks/providers) 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](/functions/yaml) and
[template variables](/templates), so no one maintains them by hand:

**File:** `stacks/orgs/acme/_defaults.yaml`

```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 }}"
```

| Tag | Set with | Returns |
|---|---|---|
| `repository` | [`!git.repository`](/functions/yaml/git.repository) | Owner and name, for example `acme/infrastructure` |
| `source-branch` | [`!git.branch`](/functions/yaml/git.branch) | Current branch |
| `source-commit` | [`!git.sha`](/functions/yaml/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:

**File:** `stacks/orgs/acme/payments/_defaults.yaml`

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

**File:** `stacks/orgs/acme/payments/dev.yaml`

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

```shell
atmos describe component app --stack=payments-dev --query=.providers
```

```yaml
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.

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

**File:** `stacks/mixins/ci-source-tags.yaml`

```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](/stacks/imports#conditional-imports), so it applies only in CI and
developers keep the Git functions on their workstations:

**File:** `stacks/orgs/acme/payments/dev.yaml`

```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](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/guides/resource-tagging) for the current list.

:::note 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.
:::

### Null label (any cloud)

[`terraform-null-label`](https://github.com/cloudposse/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.

:::note Adapt the convention to your provider
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](https://docs.cloud.google.com/compute/docs/labeling-resources#requirements)
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](/functions/yaml) and [template variables](/templates):

**File:** `stacks/orgs/acme/_defaults.yaml`

```yaml
# Organization level: label context and tags every stack in the organization receives.
vars:
  namespace: acme
  # Case of the tags the label module generates (name, namespace, tenant, stage).
  # It does not change the tags under `vars.tags`, so those are written in lowercase below.
  label_key_case: lower
  label_value_case: lower
  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 }}"
    region: us-east-2
```

`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](#enforce-the-convention).

The tenant level adds the tenant and the product tags:

**File:** `stacks/orgs/acme/payments/_defaults.yaml`

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

vars:
  tenant: payments
  tags:
    # The label module passes these through as written, so they follow the lowercase convention.
    product: checkout
    owner: payments-team
```

The stage level adds the stage and the tier tag:

**File:** `stacks/orgs/acme/payments/dev.yaml`

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

vars:
  stage: dev
  tags:
    tier: nonprod
```

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:

```shell
atmos describe component app --stack=payments-dev --query=.vars
```

```yaml
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:

```shell
atmos terraform plan app --stack=payments-dev
```

```text
  # 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"
    }
```

:::warning Source tags change between runs
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`](/functions/yaml/labels#bridging-metadatalabels-into-varstags) 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`](/functions/yaml/labels). See [component metadata](/stacks/components/component-metadata#tags) 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](/validation/validating) 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](https://aws.amazon.com/blogs/mt/implement-aws-resource-tagging-strategy-using-aws-tag-policies-and-service-control-policies-scps/).
- **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](https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_tag-policies-enforcement.html).
- **Detect missing tags on existing resources.** Configure AWS Config's
  [`required-tags` rule](https://docs.aws.amazon.com/config/latest/developerguide/required-tags.html) 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](https://docs.aws.amazon.com/organizations/latest/userguide/enforce-required-tag-keys-iac.html)
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`:

```shell
# AWS provider default_tags
atmos init examples/tagging-default-tags
cd tagging-default-tags
atmos describe component app --stack=dev --query=.providers
```

```shell
# 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](/stacks/providers): the full `providers` reference and merge behavior
- [Setting Default Tags](/components/terraform/providers#setting-default-tags): `default_tags` in the provider reference
- [`!labels`](/functions/yaml/labels): bridge component labels into a module's `tags` variable
- [Git YAML functions](/functions/yaml): `!git.repository`, `!git.branch`, `!git.sha`
