Skip to content
Infrastructure as Code iac patterns 4 min read

Naming & Tagging Conventions

Consistent naming and tagging are the cheapest governance you can buy. When every resource follows a predictable name and carries a known set of tags, engineers can find things instantly, finance can attribute spend, and security can scope policies without guesswork. Terraform makes this enforceable: a handful of locals and a single default_tags block can drive thousands of resources. This page shows how to design a naming scheme, generate names programmatically, and apply mandatory tags so that consistency is the path of least resistance rather than a code-review afterthought.

Why conventions matter

Cloud accounts decay fast without rules. A resource named db-final-2 tells you nothing about its environment, owner, or purpose, and an untagged EC2 instance is invisible to cost reports. Conventions turn raw infrastructure into a searchable, auditable inventory. The two pillars are names (the human- and API-facing identifier) and tags (key/value metadata used for filtering, billing, and automation). Names should be deterministic and unique; tags should be uniform and complete.

A naming pattern

A widely used scheme is <env>-<app>-<component>[-<suffix>]. It reads left-to-right from broadest to most specific scope, sorts predictably in consoles, and stays within the character limits of most AWS resources.

SegmentExamplePurpose
envprod, stg, devDeployment environment
apppayments, authOwning application or service
componentapi, db, queueResource role
suffix01, use1Disambiguator (region, index)

Define the building blocks once in locals and compose a name_prefix so individual resources only append their component.

locals {
  env     = "prod"
  app     = "payments"
  region  = "us-east-1"
  region_short = { "us-east-1" = "use1", "eu-west-1" = "euw1" }

  name_prefix = "${local.env}-${local.app}"
}

resource "aws_ecs_cluster" "this" {
  name = "${local.name_prefix}-api"
}

resource "aws_db_instance" "primary" {
  identifier        = "${local.name_prefix}-db"
  engine            = "postgres"
  engine_version    = "16.3"
  instance_class    = "db.r6g.large"
  allocated_storage = 100
  username          = "app"
  manage_master_user_password = true
  skip_final_snapshot         = false
  final_snapshot_identifier   = "${local.name_prefix}-db-final"
}

Some resources reject hyphens or impose tight length limits. S3 bucket names are globally unique and DNS-constrained, while IAM roles cap at 64 characters. Validate names with can(regex(...)) in a variable validation block rather than discovering the limit at apply time.

Tagging strategy

The provider-level default_tags block applies a baseline set of tags to every taggable resource it manages, so you never tag individual resources by hand for the common keys. Resource-level tags are merged on top and win on conflict.

provider "aws" {
  region = local.region

  default_tags {
    tags = {
      Environment = local.env
      Application = local.app
      ManagedBy   = "terraform"
      Owner       = "[email protected]"
      CostCenter  = "CC-4471"
      Repository  = "github.com/acme/payments-infra"
    }
  }
}

Now any resource you declare inherits those six tags automatically:

resource "aws_sqs_queue" "events" {
  name = "${local.name_prefix}-events"

  tags = {
    Component = "queue"
  }
}

Output:

  # aws_sqs_queue.events will be created
  + resource "aws_sqs_queue" "events" {
      + name     = "prod-payments-events"
      + tags     = {
          + "Component" = "queue"
        }
      + tags_all = {
          + "Application" = "payments"
          + "Component"   = "queue"
          + "CostCenter"  = "CC-4471"
          + "Environment" = "prod"
          + "ManagedBy"   = "terraform"
          + "Owner"       = "[email protected]"
          + "Repository"  = "github.com/acme/payments-infra"
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

The merged result surfaces in the read-only tags_all attribute, which is what AWS actually receives. OpenTofu implements default_tags identically, so this pattern is portable across both binaries.

Enforcing consistency

Conventions only hold if they are checked. Use variable validation to reject malformed inputs, and a policy engine to reject missing tags before apply.

variable "env" {
  type = string
  validation {
    condition     = contains(["dev", "stg", "prod"], var.env)
    error_message = "env must be one of dev, stg, prod."
  }
}

For organization-wide rules, gate terraform plan output with a policy. A minimal OPA/Conftest rule that fails when a mandatory tag is absent:

deny[msg] {
  resource := input.resource_changes[_]
  required := {"Environment", "Owner", "CostCenter"}
  missing  := required - {k | resource.change.after.tags_all[k]}
  count(missing) > 0
  msg := sprintf("%s missing tags: %v", [resource.address, missing])
}

Run it in CI against a saved plan:

terraform plan -out=tfplan
terraform show -json tfplan > plan.json
conftest test plan.json --policy policy/

This keeps drift out of the codebase: a pull request that forgets CostCenter fails the pipeline rather than silently shipping an unbillable resource.

Best practices

  • Centralize naming logic in locals (a name_prefix) so a single edit re-bases every resource consistently.
  • Use default_tags for organization-wide tags and reserve resource-level tags for component-specific keys only.
  • Treat a small set of tags — Environment, Owner, CostCenter — as mandatory and enforce them with policy-as-code in CI.
  • Validate env/app inputs with validation blocks and regex so illegal names fail at plan, not apply.
  • Account for per-resource constraints (length limits, no hyphens, global uniqueness) instead of assuming one format fits all.
  • Keep tag keys in stable PascalCase and document the allowed values; inconsistent casing fragments cost reports.
  • Avoid encoding mutable data (like an IP or version) into names — names are effectively immutable once dependents reference them.
Last updated June 14, 2026
Was this helpful?