Finding forgotten CloudFormation stacks in your AWS account
You open the CloudFormation console and the list is longer than you expected. Some stack names you recognise. Some look like experiments. A few were created by people who left two years ago, and one is called test-do-not-delete.
If you suspect your account has stacks nobody owns any more, the first job is to find them all, in every region, with enough context to decide what each one is. This guide gives you a read-only AWS CLI script that does that, shows how to compare the results against your repositories, and is clear about what the script can’t see.
It covers finding stacks, not deleting them. Once you have a list you’re confident about, Deleting CloudFormation stacks when the template is removed from Git covers how to remove them safely, and how to stop new ones piling up.
How stacks get forgotten
Most forgotten stacks come from a handful of predictable places:
- The template was removed from the repository, but the stack wasn’t. Most Git-based pipelines create and update stacks, and nothing in them deletes a stack when its template disappears.
- A create failed and nobody cleaned up. A stack in
ROLLBACK_COMPLETEhas rolled back its resources but still exists, and can’t be updated. Its only remaining use is to be deleted. - A change set was created but never run. Creating a stack through a change set leaves it in
REVIEW_IN_PROGRESSuntil someone executes the change set. If nobody does, the empty stack stays. - A tool was retired. Stacks deployed by a service or tool you’ve stopped using keep running after it’s gone. If you used AWS Proton, which reached end of support on 7 October 2026, its stacks are still in your account. Our Proton migration guide covers what happens to them.
- Someone deployed from their laptop. A quick
aws cloudformation deployorcdk deployto try something out never makes it into a repository at all.
Why the console doesn’t make this easy
CloudFormation is a regional service. The console shows one region at a time, so a stack someone created in us-east-1 by accident while everything else lives in ap-southeast-2 won’t appear until you go looking for it. The console also has no concept of “unused”. It tells you when a stack was created and last updated, which is a start, but you have to compare that across regions and accounts yourself.
The AWS CLI can do the comparing for you.
The script
This script lists every stack in every region enabled for one account, flags stacks that haven’t been created or updated within a number of days, and prints each stack’s tags. It’s read-only: it calls sts get-caller-identity, ec2 describe-regions and cloudformation describe-stacks, and nothing else.
#!/usr/bin/env bash
# find-cfn-stacks.sh
# Lists every CloudFormation stack in every enabled region of one AWS account,
# flags stacks not changed in N days, and prints their tags.
# Read-only: needs cloudformation:DescribeStacks and ec2:DescribeRegions.
#
# Usage: ./find-cfn-stacks.sh [days] [aws-profile] > stacks.tsv
# days flag stacks not created or updated in this many days (default 180)
# aws-profile optional named profile; otherwise your current credentials are used
set -euo pipefail
DAYS="${1:-180}"
if [[ -n "${2:-}" ]]; then
export AWS_PROFILE="$2"
fi
if ! [[ "$DAYS" =~ ^[0-9]+$ ]]; then
echo "days must be a whole number, got: $DAYS" >&2
exit 1
fi
# Cutoff timestamp in UTC. GNU date (Linux) first, then BSD date (macOS).
if ! CUTOFF=$(date -u -d "${DAYS} days ago" +%Y-%m-%dT%H:%M:%S 2>/dev/null); then
CUTOFF=$(date -u -v-"${DAYS}"d +%Y-%m-%dT%H:%M:%S)
fi
# Region used for the account and region lookups: your configured region,
# or us-east-1 if none is set.
HOME_REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-$(aws configure get region 2>/dev/null || true)}}"
HOME_REGION="${HOME_REGION:-us-east-1}"
ACCOUNT=$(aws sts get-caller-identity --region "$HOME_REGION" \
--query Account --output text)
REGIONS=$(aws ec2 describe-regions --region "$HOME_REGION" \
--query 'Regions[].RegionName' --output text)
# One tab-separated line per stack:
# name, status, created, last changed, parent stack ID (or -), tags as key=value (or -)
# shellcheck disable=SC2016 # backticks are JMESPath literals, not shell
QUERY='Stacks[].[StackName, StackStatus, CreationTime, LastUpdatedTime || CreationTime, ParentId || `"-"`, join(`","`, (Tags || `[]`)[].join(`"="`, [Key, Value])) || `"-"`]'
printf 'account\tregion\tstack\tstatus\tcreated\tlast_changed\tflags\ttags\n'
TOTAL=0
FLAGGED=0
for REGION in $REGIONS; do
if ! ROWS=$(aws cloudformation describe-stacks --region "$REGION" \
--query "$QUERY" --output text); then
echo "warning: could not read stacks in $REGION, skipping" >&2
continue
fi
[[ -z "$ROWS" ]] && continue
while IFS=$'\t' read -r NAME STATUS CREATED CHANGED PARENT TAGS; do
FLAGS=""
[[ "$CHANGED" < "$CUTOFF" ]] && FLAGS+=",STALE"
[[ "$PARENT" != "-" ]] && FLAGS+=",NESTED"
[[ "$NAME" == StackSet-* ]] && FLAGS+=",STACKSET"
case "$STATUS" in
ROLLBACK_COMPLETE) FLAGS+=",FAILED_CREATE" ;;
REVIEW_IN_PROGRESS) FLAGS+=",NEVER_EXECUTED" ;;
*_FAILED) FLAGS+=",NEEDS_ATTENTION" ;;
esac
if [[ -n "$FLAGS" ]]; then
FLAGS="${FLAGS#,}"
FLAGGED=$((FLAGGED + 1))
else
FLAGS="-"
fi
TOTAL=$((TOTAL + 1))
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$ACCOUNT" "$REGION" "$NAME" "$STATUS" "${CREATED:0:10}" "${CHANGED:0:10}" "$FLAGS" "$TAGS"
done <<< "$ROWS"
done
echo "Account $ACCOUNT: $TOTAL stacks, $FLAGGED flagged (cutoff ${CUTOFF:0:10})" >&2
Save it as find-cfn-stacks.sh, make it executable and run it:
chmod +x find-cfn-stacks.sh
./find-cfn-stacks.sh 180 > stacks.tsv
It needs the AWS CLI (version 2 or a recent version 1) and credentials allowed to call cloudformation:DescribeStacks and ec2:DescribeRegions. The AWS managed ReadOnlyAccess policy covers both. It handles both GNU date (Linux) and BSD date (macOS) when working out the cutoff.
The output is tab-separated, one row per stack, so it opens cleanly in a spreadsheet. Lined up with column -t, it looks like this:
account region stack status created last_changed flags tags
123456789012 ap-southeast-2 orders-api UPDATE_COMPLETE 2023-02-14 2026-09-30 - managed-by=acme/orders,env=prod
123456789012 ap-southeast-2 orders-api-test CREATE_COMPLETE 2024-05-02 2024-05-02 STALE -
123456789012 us-east-1 vpc-experiment ROLLBACK_COMPLETE 2025-11-19 2025-11-19 STALE,FAILED_CREATE -
123456789012 eu-west-1 StackSet-baseline-0f1e2 CREATE_COMPLETE 2024-01-08 2024-01-08 STALE,STACKSET -
A summary line (Account 123456789012: 4 stacks, 3 flagged) goes to the terminal, not the file.
To cover several accounts, run it once per named profile:
for PROFILE in prod staging sandbox; do
./find-cfn-stacks.sh 180 "$PROFILE" > "stacks-$PROFILE.tsv"
done
Reading the flags
| Flag | What it means | What to do with it |
|---|---|---|
STALE | Not created or updated within the number of days you passed in | A prompt to look, not a verdict. See the limits below. |
FAILED_CREATE | Status is ROLLBACK_COMPLETE: the create failed and rolled back | Almost always safe to remove, after a quick look at why it failed |
NEVER_EXECUTED | Status is REVIEW_IN_PROGRESS: a change set was created but never executed | Usually an abandoned deployment |
NEEDS_ATTENTION | Any *_FAILED status, such as DELETE_FAILED or UPDATE_ROLLBACK_FAILED | Something is stuck. A human needs to look whether the stack is wanted or not |
NESTED | The stack has a parent stack | Judge it by its root stack, not on its own |
STACKSET | The name starts with StackSet-, the naming pattern StackSets typically use for the stacks they create in target accounts | Managed from another account. See the limits below |
Comparing against your repositories
The script tells you what exists in the account. Deciding what’s forgotten means comparing that with what your repositories say should exist.
Start with tags. If your pipelines tag the stacks they deploy (for example managed-by=<org>/<repo>), sort the spreadsheet by the tags column. Stacks tagged with a repository you can check quickly: open the repository and look for the template. Stacks with no tags at all are your shortlist, because nothing records where they came from.
Then search your code for the remaining names. If you have local clones of your repositories under one folder, this lists every stack name that doesn’t appear anywhere in them:
# Stack names from the report, leaving out nested and StackSet stacks
awk -F'\t' 'NR > 1 && $7 !~ /NESTED|STACKSET/ {print $3}' stacks.tsv | sort -u > stack-names.txt
# Names that don't appear in any file under ~/src
while read -r NAME; do
grep -rqF --exclude-dir=.git -- "$NAME" ~/src/ || echo "$NAME"
done < stack-names.txt > not-in-any-repo.txt
Treat the result as a list of questions, not answers. Stack names are often built at deploy time from variables, such as ${ENV}-orders-api, so a stack in active use can still fail a plain text search. And a name that does match might only appear in an old README.
Checking a candidate before you call it forgotten
For each stack still on the list, two quick checks tell you a lot.
What’s in it. A stack holding an empty SQS queue is a very different decision from one holding an RDS database:
aws cloudformation list-stack-resources --stack-name vpc-experiment --region us-east-1 \
--query "StackResourceSummaries[].[ResourceType,PhysicalResourceId,LastUpdatedTimestamp]" \
--output table
Whether anything depends on it. If the stack exports outputs, other stacks may import them. The deletion guide shows how to check imports with list-imports before you remove anything.
It also works in the other direction. CloudFormation adds the tag aws:cloudformation:stack-name to the resources it creates (for most resource types that support tags), so when you come across an unfamiliar resource elsewhere, its tags usually tell you which stack it came from.
What the script can’t see
The script is a starting point. These are the places it can mislead you or miss things entirely.
“Last changed” isn’t “last used”. A stack holding a production VPC might not have been updated in three years because it’s done its job perfectly. STALE means nobody has deployed to it, not that nobody depends on it. Check resources and ask around before treating age as evidence.
Stacks created outside a pipeline. The script finds them, but your repositories won’t explain them. Some are someone’s experiment. Others are created by AWS tools and services on your behalf, such as the CDKToolkit stack from cdk bootstrap, the aws-sam-cli-managed-default stack the SAM CLI creates, or stacks Elastic Beanstalk creates for its environments. Those belong to the tool that made them. Remove them through that tool, or not at all.
Nested stacks. A nested stack only gets updated when its own template changes, so it can look stale while its root stack is updated every week. AWS recommends starting operations such as updates from the root stack rather than from the nested stack. Judge a nested stack by its root, and if the root is forgotten, deal with the root.
StackSets. A stack created by a StackSet lives in a target account, but the StackSet that owns it lives in an administrator account: your organisation’s management account, a delegated administrator account, or whichever account created it. Running the script in a target account shows you the stacks but not the StackSet. To see the other side, run this in the administrator account (add --call-as DELEGATED_ADMIN if you’re using a delegated administrator):
aws cloudformation list-stack-sets --status ACTIVE
aws cloudformation list-stack-instances --stack-set-name baseline
Removing stacks belonging to a StackSet goes through delete-stack-instances in the administrator account, not through deleting the stack in the target account.
One account per run. The script reads the account your credentials belong to. Accounts you don’t have credentials for, or forgot you had, aren’t covered.
Regions that aren’t enabled. By default, describe-regions returns only the regions enabled for your account, so the script skips opt-in regions you haven’t enabled. If you’ve disabled a region that once had stacks in it, they won’t show up.
Only stacks that still exist. describe-stacks doesn’t return deleted stacks. Resources that outlived their stack, through DeletionPolicy: Retain or a failed delete, and resources that were never in a stack at all, aren’t stacks, so the script can’t see them. If you want a record of stacks that were recently deleted, list-stacks keeps summaries of deleted stacks for 90 days.
Making it a habit
A one-off audit only fixes today. Running the script monthly and comparing this month’s file with last month’s shows you new stacks as they appear, while the person who created them still remembers why:
diff <(cut -f2,3 stacks-2026-09.tsv | sort) <(cut -f2,3 stacks-2026-10.tsv | sort)
Finding stacks after the fact will always be detective work. The cheaper fix is to stop creating orphans in the first place, which is what the deletion guide covers.
Where Stackmuster fits
To be clear about what it won’t do: Stackmuster won’t find the stacks this script finds. It works from your repositories. In AWS it never scans anything outside CloudFormation, and it won’t discover stacks it didn’t deploy. For stacks that already exist, the script above is the right tool. Once you’ve found a stack you want to keep, the plan is that you’ll be able to write a template for it and add that to a repository Stackmuster scans. If a stack with that name already exists, Stackmuster will stop and ask before taking it over, rather than deciding for you.
What we’re building Stackmuster for is the other half of the problem: making sure templates in your repositories don’t turn into new forgotten stacks. It’s planned to scan your repositories for CloudFormation templates and treat each one as a deployment with a full lifecycle: create, update, and deletion when the template is removed. A stack deployed that way lives exactly as long as its template does.
Stackmuster isn’t available yet. Early access opens in November 2026. If you’d like to hear when it does, join the early access list.
If you’re cleaning up after AWS Proton, our Proton migration guide covers what happens to your stacks and the options AWS recommends. And if you’re comparing deployment options, see how Stackmuster compares with CloudFormation Git sync.
Sources
- AWS CLI reference: describe-stacks
- AWS CLI reference: list-stacks
- AWS CLI reference: list-stack-resources
- AWS CLI reference: describe-regions
- AWS CloudFormation API Reference: Stack (fields and stack status values)
- AWS CloudFormation User Guide: Split a template into reusable pieces using nested stacks
- AWS CloudFormation User Guide: StackSets concepts
- AWS CloudFormation User Guide: Delete stacks from CloudFormation StackSets
- AWS CloudFormation Template Reference: CloudFormation resource tagging