AWS Resource Tagging
Pattern for applying a consistent tagging taxonomy to all AWS resources for cost allocation, operational traceability, and client billing.
Status: Approved Type: Organizational (Required*) ADR: ADR-0006 — AWS Resource Tagging Taxonomy
*Required for all repositories that provision AWS resources.
Problem
AWS bills aggregate all resource costs by default. Without a tagging standard there is no reliable way to:
- Attribute spend to a product, team, or client engagement
- Reconcile infrastructure costs against client invoices
- Trace a running resource back to the Terraform code that created it
- Route cost anomaly alerts or security findings to the right owner
Context
When to Use This Pattern
- Provisioning any AWS resource via Terraform (Lambda, SQS, DynamoDB, S3, IAM roles, etc.)
- Creating a new project, client engagement, or internal workstream
- Remediating existing untagged resources
When NOT to Use This Pattern
- Resources provisioned outside AWS (tagging has no equivalent in other contexts)
- Local sandbox resources that never reach AWS (e.g. Docker Compose, LocalStack)
Solution
Apply a three-tier tag set to every AWS resource. Tags are defined once in a Terraform locals block and injected via default_tags on the AWS provider. No per-resource tag blocks unless an individual resource needs an override.
Tag Taxonomy
Tier 1 — Required (all resources)
Every AWS resource MUST carry these tags without exception.
| Tag key | Purpose | Values |
|---|---|---|
product | Ontopix product line that owns this resource | agents · audits · mistery · platform · shared |
billing-mode | Who bears the cost | client · saas · internal · rd |
env | Deployment environment | prod · pre · dev · sandbox |
team | Team accountable for this resource | engineering · marketing · operations · infra · finance |
owner | Accountable email — team alias or personal address | platform@ontopix.ai · engineering@ontopix.ai |
source | Terraform root module path that created this resource | ontopix/infra/global · ontopix/my-service/.infra |
managed-by | Provisioning method | terraform · manual · cdk |
billing-mode reference
| Value | Meaning |
|---|---|
client | Cost is attributable to a specific client; may be recovered through invoicing |
saas | Cost belongs to the shared SaaS platform, not a specific client |
internal | Ontopix internal tooling — not tied to any client or product line |
rd | Research and development; no current billing target |
env reference
| Value | Meaning |
|---|---|
prod | Production — live, customer-facing |
pre | Pre-production — mirrors production |
dev | Development — active integration and development testing |
sandbox | Isolated developer sandbox; ephemeral, non-shared |
source convention
Format: ontopix/{repository-name}/{path-to-root-module}
The path is the directory containing the Terraform root module (the directory with backend.tf). This is always a static string — never interpolated from a variable.
ontopix/infra/global
ontopix/schemas/.infra
ontopix/mcp-services/examples/hello-world/.infra
For repositories with a single .infra/ directory, set source once in locals.tf. For repositories with multiple root modules (e.g. per-environment directories), override source per-module as a local.
owner convention
Use team aliases for shared or long-lived infrastructure (platform@ontopix.ai, engineering@ontopix.ai). Use personal addresses only for short-lived or individually-owned resources. Personal addresses must be updated when ownership changes.
Tier 2 — Contextual (required when billing-mode = client)
These tags provide the detail needed to attribute costs to a specific client engagement and produce accurate invoices. They are not used on saas, internal, or rd resources.
| Tag key | Purpose | Values / format |
|---|---|---|
client | Client identifier | Lowercase, single word or acronym (e.g. acme, retailco) |
project | Specific engagement within the client relationship | Short slug (e.g. support-agent-v1, audit-pilot) |
cost-center | Finance cost center ID for billing roll-up | Provided by Finance — use tbd until the registry is published |
component | Architectural layer this resource belongs to | api · worker · db · infra · ml · frontend · edge |
client: No canonical registry exists yet. Use lowercase, no hyphens, no spaces. A client registry will be established separately.
project: Only set when billing-mode = client. Not applicable to other billing modes.
cost-center: The taxonomy and IDs will be defined by Finance. Set to tbd until the registry is published. Every billing-mode = client resource MUST carry this tag even with a placeholder value.
Tier 3 — Optional (extended attributes)
All Tier 3 tag keys use the prefix extra: to distinguish them from required tags and prevent future collisions. Values carry a matching type prefix.
| Tag key | Purpose | Value format |
|---|---|---|
extra:feature | Specific product feature or architectural experiment | {name} (e.g. debounce) |
extra:channel | Communication channel served by this resource | {name} (e.g. whatsapp) |
extra:data-class | Data sensitivity classification | pii · internal · public |
The extra: prefix means Tier 3 tags are immediately recognisable in Cost Explorer filters and AWS Config, and can be excluded or included as a group in IAM tag conditions.
Implementation
Step 1 — Define tags in locals.tf
# .infra/locals.tf
locals {
tags = {
# Tier 1 — always required
product = "agents"
billing-mode = "client"
env = var.environment
team = "engineering"
owner = "engineering@ontopix.ai"
source = "ontopix/my-service/.infra"
managed-by = "terraform"
# Tier 2 — required because billing-mode = "client"
client = "acme"
project = "support-agent-v1"
cost-center = "tbd" # replace with Finance-issued ID
component = "worker"
# Tier 3 — optional, add as needed
"extra:channel" = "channel-whatsapp"
}
}
Step 2 — Inject via default_tags on the AWS provider
# .infra/providers.tf
provider "aws" {
region = var.aws_region
default_tags {
tags = local.tags
}
}
default_tags propagates the tag set to every resource managed by this provider. Resources that need an override (e.g. a shared bucket spanning multiple components) can declare individual tags blocks — these merge with and take precedence over default_tags.
Step 3 — Validate the env variable
# .infra/variables.tf
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["prod", "pre", "dev", "sandbox"], var.environment)
error_message = "environment must be one of: prod, pre, dev, sandbox"
}
}
Step 4 — Enforce Tier 1 tags via AWS Config (central infra repo)
The central infra repository maintains a Config rule that flags any resource missing a Tier 1 tag. Enable in alerting-only mode initially; block non-compliant creates via SCP after the remediation window closes.
Alerts route to the resource owner and to infra@ontopix.ai as a catch-all. Where feasible, create a GitHub issue on the repository identified by the source tag.
# infra/global/config/tagging.tf
resource "aws_config_config_rule" "required_tags" {
name = "ontopix-required-tags"
source {
owner = "AWS"
source_identifier = "REQUIRED_TAGS"
}
input_parameters = jsonencode({
tag1Key = "product"
tag2Key = "billing-mode"
tag3Key = "env"
tag4Key = "team"
tag5Key = "owner"
tag6Key = "source"
# tag7Key = "managed-by" # enable after remediation window
})
}
Applies Principles
- Evidence Over Assumptions — tagged resources produce cost data; untagged resources require guesswork.
- Ownership & Responsibility —
ownerandteammake accountability visible in billing reports and security findings without manual lookup. - Automation Over Manual Work —
default_tagson the provider is a one-time definition; tagging is never per-resource boilerplate. - Security by Design —
managed-by=manualis a drift signal;ownerroutes security findings to the right contact. - Long-Term Thinking — the taxonomy maps directly to AWS Organizations account-level separation when multi-account structure is adopted.
Consequences
| ✅ | Per-client, per-product, and per-team cost reports immediately available in AWS Cost Explorer |
| ✅ | source enables one-click navigation from any AWS resource to the exact Terraform module that created it |
| ✅ | owner routes cost anomaly alerts and security findings without manual lookup |
| ✅ | billing-mode + client tags map directly to future AWS Organizations account-level separation |
| ✅ | AI agents provisioning infrastructure have an explicit, enforceable tagging contract |
| ⚠️ | source cannot be set via default_tags alone in repositories with multiple Terraform root modules — requires per-module local override |
| ⚠️ | cost-center carries tbd until Finance publishes the cost center registry — limits invoice reconciliation accuracy in the short term |
| ⚠️ | Existing untagged resources require a one-time remediation pass before Config enforcement is enabled |
| ⚠️ | billing-mode=rd can accumulate spend silently — review rd-tagged costs monthly |
Examples
SaaS platform resource
locals {
tags = {
product = "agents"
billing-mode = "saas"
env = var.environment
team = "engineering"
owner = "engineering@ontopix.ai"
source = "ontopix/agents-service/.infra"
managed-by = "terraform"
component = "worker"
}
}
Internal shared infrastructure
locals {
tags = {
product = "shared"
billing-mode = "internal"
env = var.environment
team = "infra"
owner = "platform@ontopix.ai"
source = "ontopix/infra/global"
managed-by = "terraform"
}
}
R&D workload
locals {
tags = {
product = "platform"
billing-mode = "rd"
env = "sandbox"
team = "engineering"
owner = "engineering@ontopix.ai"
source = "ontopix/experiments/.infra"
managed-by = "terraform"
"extra:feature" = "new-retrieval-pipeline"
}
}
Multi-component repository
When a repository provisions resources spanning multiple components, define a base tag set and merge per component:
# .infra/locals.tf
locals {
base_tags = {
product = "agents"
billing-mode = "client"
env = var.environment
team = "engineering"
owner = "engineering@ontopix.ai"
source = "ontopix/my-service/.infra"
managed-by = "terraform"
client = "acme"
project = "support-agent-v1"
cost-center = "tbd"
}
api_tags = merge(local.base_tags, { component = "api" })
worker_tags = merge(local.base_tags, { component = "worker" })
infra_tags = merge(local.base_tags, { component = "infra" })
}
Reference the appropriate local per resource group rather than overriding in individual resource blocks.
AI Agent Rules
When provisioning AWS resources, agents MUST:
- Apply all Tier 1 tags on every resource — there are no exceptions.
- Apply Tier 2 tags when
billing-mode = client. Ifclient,project, orcost-centervalues are not already defined in the repository'slocals.tf, stop and ask the engineer before proceeding. - Never default to
billing-mode=rdwithout explicit instruction — it obscures costs. - Set
managed-by=terraformon all Terraform-provisioned resources. - Set
sourceto the correctontopix/{repo}/{path}for the root module being worked in. - Flag any existing resource in the repository that is missing Tier 1 tags and surface it to the engineer.
Variations
Overriding tags on individual resources
For a resource that belongs to a different component than the module default:
resource "aws_s3_bucket" "static_assets" {
bucket = "my-service-static-assets"
tags = {
component = "frontend" # overrides the module-level default
}
}
AWS merges this with default_tags; the component key is overridden, all other tags are inherited.
Related Patterns
- Infrastructure Layout — where
.infra/andlocals.tflive within a repository - Sandbox Environments — sandbox resources use
env=sandboxand typicallybilling-mode=internal - GitHub Actions Workflows — CI pipelines should surface tag validation via
terraform planoutput before applying