Every tagging guide I’ve read assumes a greenfield. Write a schema, put it in the landing zone, enforce it from day one. Nice if you have it. Most of us don’t. We have six years of accounts, three spellings of Environment, an owner tag that points at Dave, who left in 2023 (Dave is made up, but you have one), and a finance team asking why a third of the bill rolls up to “No tag key.”
This is a guide for that situation. It assumes you can’t stop the world, can’t re-deploy everything and can’t get every team to drop what they’re doing for a month. It’s the order I’d do things in, with the commands and policies I’d use, and the places where the cloud providers’ tooling will quietly let you down.
The short version: don’t start with tags. Start with the structure you already have, fix tags where the money is, allocate the rest with rules, and only then turn on enforcement.
What the data says
There’s no good primary data on what share of cloud spend is untagged. You’ll see vendor blogs quote “30 to 50% untagged,” but I couldn’t find a survey behind that number, so I’d treat it as folklore. What does exist points the same way:
- The CNCF’s 2023 Cloud Native FinOps microsurvey found that only 19% of respondents had accurate Kubernetes cost information. 40% estimated it and 38% had no monitoring at all. Asked what caused overspend, 70% said over-provisioning, and 45% named “a lack of awareness of responsibility at an individual or team level.” That’s a small survey (just over 100 responses), but the second answer is an ownership problem, and ownership is what tags are supposed to carry.
- Flexera’s 2026 State of the Cloud Report (753 respondents) puts estimated wasted IaaS and PaaS spend at 29%, the first increase after five years of decline. You can’t hand waste to someone who can fix it if you can’t say whose it is.
- The FinOps Foundation’s State of FinOps 2026 (1,192 respondents, more than $83 billion in annual cloud spend) found 98% of FinOps teams now manage AI spend, up from 31% two years earlier. New kinds of spend keep arriving, usually from new teams, and usually before anyone has written down who owns it.
So measure your own number. It takes an afternoon, and Phase 1 below shows how.
Design a small taxonomy
Retrofits most often fail on an ambitious schema. Require 15 tags and you’ll get 15 tags of garbage. Pick five or six, make them mandatory, and let teams add optional ones later.
| Key | What it’s for | Example values | Required | Allowed values |
|---|---|---|---|---|
owner |
Who to escalate to (a team alias, not a person) | team-payments@example.com |
Yes | Email or alias format |
team |
Showback and chargeback grouping | payments, platform |
Yes | Enumerated list |
cost-center |
Mapping to finance | cc-4200 |
Yes | Enumerated, from finance |
env |
Lifecycle and automation scoping | prod, staging, dev, sandbox |
Yes | Enumerated |
service |
Which application or service | checkout-api |
Yes | Free text, lowercase |
managed-by |
Spotting drift and hand-built resources | terraform, manual, helm |
Recommended | Enumerated |
Use team aliases, not people. People leave, and a tag that names a person starts going stale the day they hand in their notice. Keep personal data and secrets out of tags entirely. Both AWS and Microsoft warn that tags are plain text that shows up in billing data and plenty of other places.
Pick names that work everywhere
Your schema has to be valid on every platform you run, and they don’t agree:
| AWS tags | Azure tags | GCP labels | Kubernetes labels | |
|---|---|---|---|---|
| Max per resource | 50 user-defined (aws: tags don’t count) |
50 (a few types, like Automation and CDN, only 15) | 64 | No count limit |
| Key length | 128 | 512 (128 on storage accounts) | 63 | 63 for the name, plus an optional prefix |
| Value length | 256 | 256 | 63 | 63 |
| Case | Keys and values case-sensitive | Names case-insensitive, values case-sensitive | Lowercase only | Case-sensitive |
Sources: AWS tagging best practices, Azure tag limits, Google Cloud labels, Kubernetes labels.
My rule: lowercase keys and values, hyphens instead of spaces or underscores, 63 characters or fewer, starting with a letter. That’s valid on all four. It also gets you out of AWS treating Production and production as two different cost groups, and out of Azure’s opposite habit, where tag names collide regardless of case but values don’t.
Know what billing does with your tags
AWS. A tag on a resource does nothing for billing until someone in the management account activates it as a cost allocation tag. New keys can take up to 24 hours to show up for activation, and up to another 24 hours to become active. You can backfill activation for up to 12 months, but read the fine print: backfill applies today’s activation status to past months. It doesn’t add tags. If a resource didn’t carry the tag in March, March stays untagged. And if you deactivated a key, the backfill strips it from history too, so check the full activation list before you run one.
Azure. Resources don’t inherit resource group or subscription tags, and Cost Management only shows a tag on usage from while the tag was on the resource. There is a billing-side shortcut, tag inheritance, covered in Phase 4.
GCP. Labels flow into the billing export. Labels on a project don’t appear on the resources inside it.
The retrofit, in five phases
The phases overlap a bit, but the order matters.
Phase 0: Allocate by structure before you touch a tag
Map every AWS account, Azure subscription and resource group, GCP project and Kubernetes namespace to an owner and a cost center. The FinOps Foundation’s Allocation capability describes the Crawl stage as exactly this: allocating the costs of accounts, projects or subscriptions using a list of which ones belong to which cost center.
In most messy estates this alone allocates a big share of spend in a day. It also shows you where tags actually matter, which is in the shared accounts and subscriptions where several teams’ resources sit side by side. Find Unowned AWS Resources in 30 Minutes walks through building that map for AWS accounts.
On AWS, put the mapping in Cost Categories. Rules can match on account, tag, service and charge type, and since 2022 you can apply them retroactively to any month in the previous 12. Unlike tags, you get history from the day you set it up.
Phase 1: Measure
You want three numbers per platform: the share of spend that’s allocated, the share of spend carrying each required tag, and the list of key spellings people have actually used.
Start from billing data, not inventory. A thousand untagged security groups matter less than one untagged r6i.16xlarge. If you have a CUR 2.0 export (AWS Data Exports) in Athena, this ranks last month’s untagged spend by resource. It’s a read-only query:
-- CUR 2.0 in Athena. Top resources with no `team` tag, October 2026.
-- Change the billing period to the last full month when you run it.
SELECT line_item_product_code,
line_item_resource_id,
SUM(line_item_unblended_cost) AS cost
FROM cur2 -- your Data Exports table name
WHERE bill_billing_period_start_date = TIMESTAMP '2026-10-01 00:00:00'
AND line_item_line_item_type IN ('Usage', 'DiscountedUsage', 'SavingsPlanCoveredUsage')
AND COALESCE(element_at(resource_tags, 'user_team'), '') = ''
GROUP BY 1, 2
ORDER BY cost DESC
LIMIT 50;
A few notes. Only activated cost allocation tags appear in resource_tags. element_at returns NULL for a missing key, where resource_tags['user_team'] would throw an error. If you’re still on the legacy CUR, tags are separate columns instead of a map, so the filter becomes COALESCE(resource_tags_user_team, '') = '', and the billing period is usually filtered on the partition columns your setup created.
Then list the key spellings in use. On AWS, the Resource Groups Tagging API does this per Region, so loop over every enabled Region in each account. The CLI pages through results on its own. This is read-only:
# Count tag keys across all enabled Regions in one account (read-only).
for r in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
aws resourcegroupstaggingapi get-tag-keys --region "$r" --query 'TagKeys[]' --output text
done | tr '\t' '\n' | sort | uniq -c | sort -rn
One catch: the tagging API only knows about resources that have been tagged at some point. Resources that never had a tag won’t show up here, which is why the billing query comes first.
On Azure, one Resource Graph query covers every subscription you can read. Also read-only:
// Tag key spellings across resources, resource groups and subscriptions.
resources
| union (resourcecontainers)
| mv-expand bagexpansion=array tags
| extend tagKey = tostring(tags[0])
| where isnotempty(tagKey)
| summarize uses = count() by tagKey
| order by uses desc
Run it with az graph query -q "<query>" --first 1000. The CLI returns only 100 rows unless you set --first, and for anything bigger than 1,000 rows you page with the skip_token it returns. Add --management-groups <id> to cover a whole tenant hierarchy.
Turn what you find into a normalization map:
Env, ENV, environment, Environment, stage -> env
prod, Production, PROD, prd -> prod
CostCenter, cost_center, costcentre -> cost-center
That map drives the cleanup in Phase 3, and it’s also your best argument for why enforcement is needed.
Phase 2: Fix the expensive stuff first, and work out who owns it
Sort untagged spend from highest to lowest and draw a line at roughly the top 80% of dollars. That list is usually far shorter than the full inventory. The long cheap tail gets handled by structure and allocation rules in Phase 4.
For each resource above the line, work out the owner from the strongest evidence you have:
- Infrastructure as code. Search Terraform state, CloudFormation stacks or Pulumi stacks for the resource ID. Whoever owns the repo owns the resource.
- Where it lives. If Phase 0 is done, the account, subscription or project often settles it.
- Who created it. AWS has an AWS-generated
aws:createdBycost allocation tag that records the identity that created a resource. Turn it on today, but know its limits: it only applies to resources created after you activate it, only for a fixed list of resource types, only in 14 Regions, and on a best-effort basis from CloudTrail. For older resources, look up the create event in CloudTrail or the Azure Activity Log, both of which only keep 90 days by default. Who Created This AWS Resource? shows how to search further back. - The name.
payments-prod-db-01is a strong hint. - Kubernetes metadata. Namespace, owner references and service accounts often point at a team even when pods carry no cost labels.
Write down a confidence level for each guess. Publish the confident ones and send the shaky ones to the teams to confirm. For the Azure side of this, How to Manage Azure Costs When Your Tagging is a Complete Mess has scripts that work from resource groups and the Activity Log.
Phase 3: Clean up in bulk
These all change resources, so do them with the right people watching:
- AWS: Tag Editor for bulk edits in the console, or
aws resourcegroupstaggingapi tag-resourcesfor scripted changes across services. If a resource is managed by IaC, fix the tags in code. Otherwise the nextapplyputs them back the way they were. - Azure: Azure Policy with the
modifyeffect plus a remediation task is the built-in way to fix tags on existing resources at scale. - GCP:
gcloud ... update --update-labels, driven from a Cloud Asset Inventory export. - Across clouds: Cloud Custodian’s
rename-tagandnormalize-tagactions are built for collapsingEnv,envandenvironmentinto one key.
On Azure, tag the resource groups well (there are far fewer of them) and let policy push those tags down. Microsoft ships a built-in policy for this, “Inherit a tag from the resource group if missing,” so you don’t need to write your own. Its rule looks like this. Assigning it and running a remediation task writes tags to your resources:
{
"if": {
"allOf": [
{ "field": "tags['cost-center']", "exists": "false" },
{ "value": "[resourceGroup().tags['cost-center']]", "notEquals": "" }
]
},
"then": {
"effect": "modify",
"details": {
"roleDefinitionIds": [
"/providers/microsoft.authorization/roleDefinitions/b24988ac-6180-42a0-ab88-20f7382dd24c"
],
"operations": [
{ "operation": "add", "field": "tags['cost-center']",
"value": "[resourceGroup().tags['cost-center']]" }
]
}
}
}
Phase 4: Allocate what you can’t tag
Some cost will never carry a useful tag: support fees, data transfer, shared NAT gateways, Marketplace charges, shared clusters. Don’t chase 100% tag coverage. Allocate these with rules instead.
AWS split charge rules. Inside a Cost Category you can split a shared cost across teams proportionally, by fixed percentages or evenly, with up to 10 split charge rules per category. The catch, in AWS’s words: the split costs “do not appear and don’t impact your AWS Cost and Usage Reports, Cost Explorer, and other AWS Cost Management tools.” They live on the Cost Categories details page and its CSV download. If your chargeback reads the CUR, it won’t see them.
Azure tag inheritance. Cost Management tag inheritance applies billing, subscription and resource group tags to the usage records of child resources. It’s available on EA, MCA and MPA (Azure plan) billing accounts, and takes about 8 to 24 hours to apply to the current month. It’s the fastest win in a messy Azure estate. Just remember the tags land on usage records and never on the resources themselves, so Azure Policy, scripts and anything else reading resource tags can’t see them. By default a resource’s own tag wins over an inherited one with the same key, and you can flip that.
Kubernetes. Allocate cluster cost to namespaces and labels by pod requests. OpenCost can aggregate by namespace or by a label such as label:team. On EKS, split cost allocation data has been able to import up to 50 pod labels as cost allocation tags since October 2025. You still have to activate them.
Some shared costs aren’t worth allocating at all. The FinOps Foundation’s framework calls this “informed ignoring”: a deliberate decision to fund something centrally. Write the decision down and move on.
Phase 5: Audit, then warn, then enforce
Enforcement that arrives before your pipelines comply breaks deploys and burns goodwill. Roll it out in three steps:
- Audit (roughly weeks 1 to 4). Policies in report-only mode. Publish a weekly compliance view by team.
- Warn (weeks 4 to 8). Soft-fail CI checks, Kyverno in
Auditmode, Cloud Custodiannotifyto owners with amark-for-opgrace period. - Enforce (week 8 on). Deny at creation for new resources, starting with expensive resource types and production accounts, with a documented way to get an exception.
Stop new mess at the source
Make the right tags the default
The cheapest enforcement is a good default. In the Terraform AWS provider (3.38.0 and later), default_tags applies tags to every resource the provider creates:
provider "aws" {
default_tags {
tags = {
team = var.team
cost-center = var.cost_center
env = var.env
service = var.service
managed-by = "terraform"
}
}
}
One gotcha: default_tags doesn’t reach the instances an Auto Scaling group launches. Read them back with the aws_default_tags data source and add them to the group with propagate_at_launch = true. CDK has Tags.of(scope).add(...) for the same job.
Check in CI
Fail the pull request, not the deploy. Checkov custom policies, TFLint’s AWS ruleset, or OPA/Conftest against terraform plan JSON can all check that required keys exist with allowed values.
AWS also has a native option now. Since November 20, 2025, Organizations tag policies can mark keys as required with report_required_tag_for, and CloudFormation (through the AWS-managed AWS::TagPolicies::TaggingComplianceValidator hook), Terraform and Pulumi can validate deployments against them in warn or fail mode.
Cloud guardrails
AWS Organizations tag policies standardize key casing and allowed values. Know their limits: on their own they only report, enforced_for blocks noncompliant tagging operations, and a resource with no tags isn’t flagged as noncompliant at all. To require a tag at creation, add a service control policy. This one, based on AWS’s own example, denies launching an EC2 instance without a team tag. Attaching it is a change, and a blunt one:
{
"Sid": "DenyRunInstancesWithoutTeamTag",
"Effect": "Deny",
"Action": "ec2:RunInstances",
"Resource": ["arn:aws:ec2:*:*:instance/*", "arn:aws:ec2:*:*:volume/*"],
"Condition": { "Null": { "aws:RequestTag/team": "true" } }
}
Because the volume ARN is in there, the team tag has to be in the tag specifications for the volumes as well as the instance. Tools that create a resource first and tag it in a second call will also fail this check. Try it on a sandbox OU before it goes anywhere near production, and for IaC the required-tags hook above is the gentler route.
Azure Policy. Once you reach the enforcement phase, pair the inheritance policies with the built-in “Require a tag on resources” (deny) at management group scope.
GCP. Custom organization policy constraints can require labels on supported resource types, such as Compute Engine instances, with a condition like 'team' in resource.labels. Google notes that a newly enforced custom constraint doesn’t apply to existing resources and recommends a dry-run policy first, which fits the audit, warn, enforce order.
Kubernetes admission control
Kyverno or OPA Gatekeeper can enforce labels at admission. Write the Kyverno rule against Pods. Kyverno then generates matching rules for Deployments, StatefulSets, Jobs and CronJobs that check the pod template, which is where cost tools like OpenCost and EKS split cost allocation data read labels from:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-cost-labels
spec:
background: true
rules:
- name: require-team-and-cost-center
match:
any:
- resources:
kinds: [Pod]
validate:
failureAction: Audit # change to Enforce after the warn phase
message: "Pods must set 'team' and 'cost-center' labels."
pattern:
metadata:
labels:
team: "?*"
cost-center: "?*"
Per-rule failureAction arrived in Kyverno 1.13. On older versions, use the policy-level validationFailureAction instead. Either way, Audit only reports, and Enforce blocks admission.
How to tell it’s working
Weight everything by spend, not resource count. Ninety-five percent of resources tagged can still leave most of the dollars unallocated if the untagged five percent are the databases.
The headline number is the share of spend allocated, whether through tags, accounts or rules. The FinOps Foundation’s maturity model gives sample targets of at least 70% of cost allocated to a known owner at Crawl, at least 85% at Walk and more than 90% at Run. You may see 80% and 90% quoted instead. Those come from an older version of the framework.
Beyond that, I’d track the share of spend carrying each required tag by team (useful for a league table), the unallocated trend month over month, and owner validity: how many owner values still resolve to a team alias that exists. Then put a monthly unallocated review and a quarterly schema review on the calendar, and treat changes to the normalization map like code.
Mistakes I’d avoid
- Enforcing before cleaning up. Deny-on-create on day one breaks pipelines and turns engineers against the whole effort.
- Counting resources instead of dollars.
- People as owners. Use team aliases.
- Tagging in the console when IaC manages the resource. The next
applyquietly reverts it. - Forgetting activation. AWS tags that aren’t activated for cost allocation never reach the bill.
- Assuming Azure resource group tags inherit. They don’t, except in billing data once tag inheritance is on.
- Labeling the Deployment but not the pod template. Cost tools read pod labels.
- Free text in enumerated fields.
cost-center: marketingnext tocc-4200makes reconciling with finance miserable.
Retrofit checklist
- Map every account, subscription, project and namespace to an owner and cost center
- Put that mapping in AWS Cost Categories and apply it to the last 12 months
- Activate
aws:createdByand your required keys as AWS cost allocation tags, then consider a backfill - Turn on Azure Cost Management tag inheritance (EA, MCA or MPA)
- Publish a five- or six-key schema: lowercase, 63 characters or fewer, enumerated values where possible
- Build a normalization map from a tag-key audit
- Rank untagged spend and work out owners for the top 80% of dollars
- Fix tags in IaC first, then in bulk with Tag Editor, the Tagging API, Azure Policy remediation or Custodian
- Write shared-cost rules: split charges, Kubernetes allocation, and what you’ll knowingly ignore
- Add
default_tagsor the equivalent, plus a CI tag check - Run tag policies, Azure Policy, GCP constraints and Kyverno in audit mode
- Warn owners with a grace period, then enforce on expensive types and production first
- Track the share of spend allocated every month against a Crawl, Walk or Run target
The part tagging won’t fix
Do all of this and your tags will still be wrong somewhere. Someone will spin up a test cluster from the console on a Friday. A team will be renamed. An owner alias will point at a group that was dissolved in a reorg. Tagging is how you record ownership, and the record drifts the moment you stop maintaining it.
That gap is why we’re building Cloud Sleuth, and why it’s designed to work without good tags. It does the Phase 2 detective work for every AWS and Azure resource, not just the expensive ones. Where a resource has a usable owner, team or cost-center tag, it uses it, and it folds variants like Owner and owner into one key. Where the tag is missing or wrong, it infers the owner from evidence that already exists: who created the resource (from CloudTrail or the Azure Activity Log), the Terraform or Bicep state and repo that manage it, the account, subscription or resource group it lives in, its naming pattern, and the owners of the resources it connects to. Each guess comes with a confidence score and the reasons behind it, so you can check the work instead of trusting a label. Resources that serve several teams, like a shared VPC, get marked as shared rather than forced onto one owner. Those owners are also the starting point for cost allocation, where virtual tags cover the Phase 4 spend that no resource tag can reach.
None of that replaces the retrofit. Keep fixing tags, because policies, CI checks and billing all read them. Cloud Sleuth covers the months in between, and it can optionally open a pull request that writes confirmed owners back into your IaC as tags, so the cleanup feeds itself. It’s pre-launch today. If you’d like to see what it finds on your own estate, messy tags and all, ask us about an Ownership Audit.