How to tag AWS resources in Terraform effectively

Leslie Alldridge
5 min readNov 28, 2023

--

Let’s take a look at how tags and default_tags work in the AWS Terraform provider — with examples. Tags are a great way to identify cost centres, owners and projects that resources relate to. In my work experience more often than not, I’ve seen inconsistency when it comes to tagging.

Security Group Terraform Example — using Tags

If we copy paste the security group example from the Terraform documentation (I’ve only slightly modified it) and run a terraform plan + apply this is what we’ll see…

# main.tf

locals {
app = "aws-tags"
}

provider "aws" {
region = "us-east-1"
}

resource "aws_security_group" "allow_tls" {
name = local.app
description = "Allow TLS inbound traffic"

ingress {
from_port = 443
to_port = 443
protocol = "tcp"
}

egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
}
Security group now exists in AWS

There’s a few resources in AWS where tags are visually important, you can see my security group name above aws-tags but the Name field is empty. To fill this in, we need to add a tag for Name (case sensitive). Whilst we’re there, we may as well add our other tags too.

locals {
app = "aws-tags"
owner = "tagging-team"
cost_centre = "platform"
slack_channel = "#help-tagging"
}

provider "aws" {
region = "us-east-1"
}

resource "aws_security_group" "allow_tls" {
name = local.app
description = "Allow TLS inbound traffic"

ingress {
from_port = 443
to_port = 443
protocol = "tcp"
}

egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}

tags = {
Name = "my security group"
app = local.app
owner = local.owner
cost_centre = local.cost_centre
slack_channel = local.slack_channel
}
}

In my Terraform plan output I can see the following changes:

      ~ tags                   = {
+ "app" = "aws-tags"
+ "cost_centre" = "platform"
+ "Name" = "my security group"
+ "owner" = "tagging-team"
+ "slack_channel" = "#help-tagging"
}
~ tags_all = {
+ "app" = "aws-tags"
+ "cost_centre" = "platform"
+ "Name" = "my security group"
+ "owner" = "tagging-team"
+ "slack_channel" = "#help-tagging"
}

tags are the tags I’ve added and tags_all is a unique set of top-level tags + resource level tags. We’ll cover this in more detail soon.

Looks good, the Name field is now filled in!

Is this the best solution?

This is the most common way I see tagging done. It does become exhausting scrolling through tagging blocks especially if the Terraform is all in a single file. I also notice most people don’t bother tagging IAM resources but that makes life harder later on when the security team ask you to delete any unused IAM resources.

Locals are also used to give you the feeling (not guarantee) of a single source of truth.

Prone to forgetfulness, typos, inconsistency, let’s try something better.

Default Tags at the provider level

Here’s the revised code leveraging default tags set at the AWS Provider level:

provider "aws" {
region = "us-east-1"
default_tags {
tags = {
app = "aws-tags"
owner = "tagging-team"
cost_centre = "platform"
slack_channel = "#help-tagging"
}
}
}

resource "aws_security_group" "allow_tls" {
name = "aws-tags"
description = "Allow TLS inbound traffic"

ingress {
from_port = 443
to_port = 443
protocol = "tcp"
}

egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
ipv6_cidr_blocks = ["::/0"]
}
}

However, when I run a Terraform plan the results look bad.

      ~ tags                   = {
- "Name" = "my security group" -> null
- "app" = "aws-tags" -> null
- "cost_centre" = "platform" -> null
- "owner" = "tagging-team" -> null
- "slack_channel" = "#help-tagging" -> null
}
~ tags_all = {
- "Name" = "my security group" -> null
# (4 unchanged elements hidden)
}
# (6 unchanged attributes hidden)

Bad because I want to keep Name as a tag. The fact that tagsis being set to null is actually fine because you’ll notice under tags_all there are 4 unchanged elements hidden — meaning my default provider tags (app, cost_centre, owner and slack_channel) are working properly and will appear in AWS without any issues. This is where most people get worried, but there’s no need to worry :)

Since the Security Group resource benefits from a cosmetic only UI (User Interface) Name tag, I’ll add that back in at the resource level.

# This block goes inside the security group resource  
tags = {
Name = "my security group"
}

Now my plan looks better:

      ~ tags                   = {
"Name" = "my security group"
- "app" = "aws-tags" -> null
- "cost_centre" = "platform" -> null
- "owner" = "tagging-team" -> null
- "slack_channel" = "#help-tagging" -> null
}
# (7 unchanged attributes hidden)

There’s no more tags_all changes and the tags being nulled out at the resource level is everything except Name. Since Name isn’t in my provider level default tags, this proves that tags_all is a unique set of provider level and resource level tags.

Note: Resource level tags have priority over default tags. So if you define app=”blah” on the security group, it’ll overrule the app tag in your provider default tags.

Add more resources

As we add more resources to this project, they inherit the default tags automatically. This is ideal as people new to Terraform + AWS (most likely to not tag resources) will get tagging for free. Someone complained about the app name you suggested? Easy fix, edit the default tag and plan + apply.

Example plan output when I added some dummy IAM resources without any tags (to demonstrate they’ll get default_tags for free):

  # aws_iam_policy.policy_two will be created
+ resource "aws_iam_policy" "policy_two" {
+ arn = (known after apply)
+ id = (known after apply)
+ name = "policy-381966"
+ name_prefix = (known after apply)
+ path = "/"
+ policy = jsonencode(
{
+ Statement = [
+ {
+ Action = [
+ "s3:ListAllMyBuckets",
+ "s3:ListBucket",
+ "s3:HeadBucket",
]
+ Effect = "Allow"
+ Resource = "*"
},
]
+ Version = "2012-10-17"
}
)
+ policy_id = (known after apply)
+ tags_all = {
+ "app" = "aws-tags"
+ "cost_centre" = "platform"
+ "owner" = "tagging-team"
+ "slack_channel" = "#help-tagging"
}
}

# aws_iam_role.example will be created
+ resource "aws_iam_role" "example" {
+ arn = (known after apply)
+ assume_role_policy = jsonencode(
{
+ Statement = [
+ {
+ Action = "sts:AssumeRole"
+ Effect = "Allow"
+ Principal = {
+ Service = "ec2.amazonaws.com"
}
+ Sid = ""
},
]
+ Version = "2012-10-17"
}
)
+ create_date = (known after apply)
+ force_detach_policies = false
+ id = (known after apply)
+ managed_policy_arns = (known after apply)
+ max_session_duration = 3600
+ name = "yak_role"
+ name_prefix = (known after apply)
+ path = "/"
+ tags_all = {
+ "app" = "aws-tags"
+ "cost_centre" = "platform"
+ "owner" = "tagging-team"
+ "slack_channel" = "#help-tagging"
}
+ unique_id = (known after apply)
}

Thanks for reading and I hope you learned something about tagging AWS resources in Terraform!

Link to code

--

--