Skip to content
governance

Find Unowned AWS Resources in 30 Minutes (Without Perfect Tags)

A time-boxed method for finding every AWS resource nobody owns: start with account ownership, add name-pattern rules, and sort what's left into orphans, shared and forgotten.

Nathaniel Fishel 12 min read

The question nobody can answer

Someone points at a line on the AWS bill, or an EC2 instance called test-2, and asks who owns it. Nobody knows. The tag that was supposed to answer that question is missing, misspelled, or names someone who left last year.

You don’t need a tagging project to fix this. You need a list: every resource that costs money, sorted into “owned”, “probably owned” and “nobody owns this”. This guide gets you that list in about 30 minutes, using read-only access and the metadata AWS already records for you.

What you need:

  • Read-only access to each AWS account you want to check. The AWS managed ReadOnlyAccess policy is enough, and so is ViewOnlyAccess for most of this.
  • AWS CLI v2, with one named profile per account.
  • A spreadsheet. The output is a tab-separated file you sort and annotate.

Nothing in this guide changes, tags or deletes anything.

Why Cost Explorer and tags don’t get you there

AWS gives you two native ways to answer “who owns this?”: tags, and Cost Explorer grouped by a tag. For many mid-market teams, both run out of road quickly.

Tags are only as good as the last person who set them. Owner, owner, team and Team are four different tag keys. Values drift: payments, Payments, payments-team, pmt. Resources created by hand in the console usually have no tags at all, and resources created by automation carry whatever the template had when someone wrote it, years ago.

Cost Explorer shows money, not resources. Grouping by a cost allocation tag tells you how much spend has no value for that tag. It doesn’t tell you which resources make up that number, which is the thing you need to go and ask about. Cost allocation tags also have to be activated in the billing console before they appear at all.

Some cost can’t be tagged. Support fees, taxes, some data transfer and some marketplace charges never carry your tags, however good your tagging policy is.

The tagging API can’t see what was never tagged. aws resourcegroupstaggingapi get-resources looks like the obvious way to find resources with no owner tag, but it only returns resources that are tagged or were tagged at some point. A resource that has never had a tag doesn’t appear, and those are exactly the ones you’re looking for.

Mid-market teams don’t have someone to chase tags. Large companies put a FinOps team on tag hygiene. If you’re a platform team of three with a dozen accounts, tag compliance is one of fifty things on your list, and it never quite gets finished.

So don’t start with tags. Start with the structure that already exists.

The 30-minute plan

TimeStepOutput
0 to 5 minMap each AWS account to an ownerAn account-to-owner table
5 to 15 minPull a read-only inventory of what costs money and gets orphanedunowned.tsv
15 to 25 minApply name-pattern and stack rules to guess ownersAn owner column, with how you know
25 to 30 minSort what’s left into orphans, shared and forgottenYour unowned list, by type

Step 1: Start at the account level (0 to 5 minutes)

The fastest ownership signal in AWS is the account. If your organization gave the payments team its own production account, then everything in that account belongs to payments unless something says otherwise. One decision covers thousands of resources.

From your management account (or a delegated administrator account), list every account:

aws organizations list-accounts \
  --query "Accounts[].[Id, Name, Email]" \
  --output table

Look at the account names and the root email addresses. They often say more than any tag: aws-payments-prod@, data-platform-dev@, jsmith+sandbox@. If you’ve tagged accounts in AWS Organizations, read those too:

aws organizations list-tags-for-resource --resource-id 111122223333

Now build a small table. Be honest about confidence:

AccountNameDefault ownerConfidence
111122223333payments-prodPayments teamHigh: single-team account
222233334444data-devData platformHigh
333344445555shared-servicesNoneShared: needs resource-level rules
444455556666sandbox-jsmithJ. SmithCheck: are they still here?
555566667777legacyNoneUnknown

Three kinds of account need extra attention, because that’s where unowned resources pile up:

  • Shared accounts (shared, network, platform, tools) host resources for many teams. The account tells you nothing about who owns a given resource.
  • Personal and sandbox accounts are owned by a person, and people change teams and leave.
  • The management account often collects early experiments from before anyone set up a multi-account structure.

For single-team accounts, you’re mostly done: everything in them has a default owner. Spend the rest of the 30 minutes on the shared, personal and unknown ones.

Step 2: Pull the inventory that matters (5 to 15 minutes)

You don’t need every resource. You need the ones that cost money and tend to outlive their purpose. In most AWS estates, those are:

  • EC2 instances, including stopped ones (their EBS volumes are still billed)
  • EBS volumes that aren’t attached to anything
  • Elastic IPs that aren’t associated with anything (AWS has charged for every public IPv4 address since February 2024, attached or not)
  • Snapshots, load balancers, NAT gateways and RDS instances, which you can add once the basics work

The script below is read-only. For each AWS CLI profile you pass it, it checks every enabled region and writes one row per finding: instances with no owner tag, unattached volumes and unassociated Elastic IPs.

#!/usr/bin/env bash
# unowned-aws.sh: read-only scan for likely-unowned AWS resources.
# Usage: ./unowned-aws.sh prod-profile staging-profile shared-profile > unowned.tsv
set -euo pipefail

OWNER_KEY="${OWNER_KEY:-Owner}"   # the tag key your teams are supposed to use

# Tags AWS adds automatically, even on resources nobody tagged by hand.
NAME="Tags[?Key=='Name'] | [0].Value"
SOURCE="not_null(Tags[?Key=='aws:cloudformation:stack-name'] | [0].Value, Tags[?Key=='aws:autoscaling:groupName'] | [0].Value, Tags[?Key=='eks:cluster-name'] | [0].Value)"

# scan <kind> <ec2 subcommand and args>: run one query, prefix each row with account, region and kind
scan() {
  local kind=$1; shift
  aws --profile "$profile" --region "$region" --output text ec2 "$@" 2>/dev/null |
    while IFS= read -r row; do
      printf '%s\t%s\t%s\t%s\n' "$account" "$region" "$kind" "$row"
    done || echo "warning: $kind query failed in $account/$region" >&2
}

printf 'account\tregion\tkind\tid\tdetail\tname\tsource\tcreated\n'

for profile in "$@"; do
  account=$(aws sts get-caller-identity --profile "$profile" --query Account --output text)
  regions=$(aws ec2 describe-regions --profile "$profile" --region us-east-1 \
    --query 'Regions[].RegionName' --output text)

  for region in $regions; do
    scan instance-no-owner describe-instances \
      --query "Reservations[].Instances[?!(Tags[?Key=='${OWNER_KEY}'])][].[InstanceId, State.Name, ${NAME}, ${SOURCE}, LaunchTime]"

    scan unattached-volume describe-volumes --filters Name=status,Values=available \
      --query "Volumes[].[VolumeId, join(' ', [to_string(Size), 'GiB', VolumeType]), ${NAME}, ${SOURCE}, CreateTime]"

    scan unassociated-eip describe-addresses \
      --query "Addresses[?!AssociationId].[AllocationId, PublicIp, ${NAME}, ${SOURCE}, 'n/a']"
  done
done

Run it against the shared, personal and unknown accounts from Step 1 first, then the rest if you have time:

chmod +x unowned-aws.sh
./unowned-aws.sh shared-services legacy sandbox-jsmith > unowned.tsv

Open unowned.tsv in a spreadsheet. Two columns do most of the work in the next step:

  • name is the Name tag, which almost every resource created in the console has, even when it has no other tags.
  • source is a tag AWS adds automatically: the CloudFormation stack, Auto Scaling group or EKS cluster that created the resource. People don’t forget to set these, because people don’t set them at all. An instance with no Owner tag but a source of payments-api-prod isn’t really unowned. It belongs to whoever owns that stack.

If you’ve turned on AWS Resource Explorer, a search for tag:none is a useful cross-check: it returns resources that have no user-defined tags at all, across services the script doesn’t cover.

Step 3: Apply name-pattern and stack rules (15 to 25 minutes)

Now add an owner column and a rule column to the spreadsheet. Work down this list in order and stop at the first rule that matches. Recording which rule matched tells you how much to trust the answer later.

  1. A trustworthy owner tag. If the resource has the tag and the value names a team that exists, take it. (The script already filtered most of these out for instances.)
  2. The stack, Auto Scaling group or cluster. Whoever owns the CloudFormation stack, the Auto Scaling group or the EKS cluster owns what it created. Look up the stack’s own tags, or the repository it’s deployed from.
  3. The account default from Step 1, if the account belongs to a single team.
  4. A name pattern. Most teams name things consistently even when they don’t tag consistently.
  5. The creator. CloudTrail records who launched a resource, for the last 90 days.
  6. Neighbors. The security group, subnet, IAM instance profile and key pair often carry a team’s name when the resource doesn’t.

For rule 4, write the patterns down as a table so they’re explicit and reusable:

PatternOwnerNotes
payments-*, pay-*, checkout-*Payments
etl-*, airflow-*, dbt-*Data platform
gh-runner-*, jenkins-*PlatformCI capacity
*-bastion, vpn-*PlatformShared access
test*, temp*, new-instance*NoneToo generic to guess

Some names are useful signals on their own:

  • A person’s name (bob-test, jsmith-scratch) means a person owns it, not a team. Check whether they’re still around.
  • Security groups named launch-wizard-N were created by the EC2 console’s launch wizard, so the instance was probably launched by hand rather than by your infrastructure code.
  • Dates and ticket numbers (migration-2023-04, INC-4412-debug) point at a project or incident that has probably ended.

For rule 5, look up the creator in CloudTrail. Run it in the resource’s region, and remember that event history only goes back 90 days:

aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=ResourceName,AttributeValue=i-0abc123def4567890 \
  --query "Events[?EventName=='RunInstances'].[EventTime, Username]" \
  --output table

If the resource is older than 90 days and you send CloudTrail to an S3 bucket, you can search the full history with Athena, but that rarely fits in a 30-minute window. Mark it as “creator unknown” and move on.

By the end of this step, most rows should have an owner and a rule. The rows that don’t are your unowned set.

Step 4: Know what “unowned” looks like (25 to 30 minutes)

Not everything without an owner is the same problem. Sort the remaining rows into three groups, because each one needs a different next step.

TypeWhat it looks likeTypical examplesRisk if you just delete it
OrphanAttached to nothing, used by nothingUnattached EBS volumes, unassociated Elastic IPs, snapshots of deleted volumes, load balancers with no targetsLow to medium: the data or the IP address may still matter
SharedUsed by many teams, claimed by noneNAT gateways, transit gateways, shared VPCs, a central database, log bucketsHigh: removing it breaks several teams at once
ForgottenClearly had a purpose and an owner, onceA proof of concept, a migration leftover, an instance stopped for months, anything named after someone who leftMedium: someone may still depend on it without knowing

Orphans are where the fast, safe savings usually are. Shared resources are where the expensive mistakes happen: they often look idle from inside one account while other accounts depend on them. Forgotten resources need a human decision, which means they need someone to make it.

Sort each group by cost. If you don’t have per-resource cost yet, use size as a proxy (volume GiB, instance type) and fill in real numbers later from Cost Explorer or your Cost and Usage Report.

Safe next steps once you have the list

The list is a starting point for conversations, not a deletion plan. Here’s how to move each group forward without breaking anything.

Orphans: confirm, keep a copy, then remove.

  • For unattached volumes, check the volume’s tags and recent CloudTrail activity, then take a snapshot before you delete it. A snapshot costs less than the volume and gives you a way to recover if someone did need it.
  • For Elastic IPs, ask before you release one. Partners and customers sometimes allowlist a specific IP address, and once it’s released, you can’t count on getting it back.
  • Snapshots are orphans only if nothing needs them: check AMIs, backup plans and retention requirements first.

Shared: give it an owner of record, don’t delete it.

  • Assign shared infrastructure to the platform team (or whoever runs it) as owner of record, even if the cost will eventually be split.
  • Don’t try to work out the cost split yet. Getting an owner on it comes first.

Forgotten: announce, wait, stop, wait, then delete.

  1. Post the list in the relevant team channels with a clear deadline: “These resources have no owner. Claim them by the 30th or they’ll be stopped.”
  2. Tag each one so the plan is visible on the resource itself, for example cleanup-candidate=2026-11-30.
  3. After the deadline, stop instances rather than terminating them, and leave them stopped for another notice period.
  4. Then delete, keeping a final snapshot for a fixed retention period.

For all three: write the answers down. Every owner you identify is an answer you don’t want to work out again next quarter. Put it in an owner tag, or at minimum in a shared ownership sheet keyed by account, stack and name pattern. The rules you wrote in Step 3 are the start of an ownership model.

Measure ownership coverage, not just savings. Track the share of resources, and the share of spend, that has an owner. Savings are a one-off. Coverage tells you whether the next round will be easier than this one.

Why this doesn’t stay done

You can run this method again next month, and you’ll need to. In the meantime, new resources appear, names drift, people change teams and the stack that explained an instance gets deleted while the instance stays. The rules you wrote down are only useful if something applies them every time, to every account.

That’s the gap Cloud Sleuth’s free local tool is being built for. It will run on your machine or in your VPC as a single binary or Docker container, connect to a handful of AWS accounts and Azure subscriptions with read-only access, and apply the same kind of ownership rules this guide uses (by account, subscription and name pattern). Built-in policies flag missing owners, untagged resources and common orphans, and nothing leaves your environment.

It’s pre-launch. Get early access and we’ll let you know when you can run the same check across your own accounts in a few minutes instead of 30.

Checklist

  • Every AWS account mapped to a default owner, or marked shared, personal or unknown
  • Read-only inventory of instances without an owner tag, unattached volumes and unassociated Elastic IPs
  • Owner rules applied in order: tag, stack or cluster, account, name pattern, creator, neighbors
  • Remaining resources sorted into orphans, shared and forgotten, by cost
  • Owners of record assigned to shared resources
  • Claim notices sent for forgotten resources, with a deadline
  • Ownership rules written down where the next person can find them
  • Ownership coverage measured, so you can compare next month

Free local tool

Find out who owns your AWS and Azure resources

Get early access to the free local tool. It runs on your machine or in your VPC and shows which resources have no owner and where spend isn't allocated. Nothing leaves your environment.