Blog

Finding forgotten CloudFormation stacks in your AWS account

Stackmuster

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_COMPLETE has 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_PROGRESS until 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 deploy or cdk deploy to 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

FlagWhat it meansWhat to do with it
STALENot created or updated within the number of days you passed inA prompt to look, not a verdict. See the limits below.
FAILED_CREATEStatus is ROLLBACK_COMPLETE: the create failed and rolled backAlmost always safe to remove, after a quick look at why it failed
NEVER_EXECUTEDStatus is REVIEW_IN_PROGRESS: a change set was created but never executedUsually an abandoned deployment
NEEDS_ATTENTIONAny *_FAILED status, such as DELETE_FAILED or UPDATE_ROLLBACK_FAILEDSomething is stuck. A human needs to look whether the stack is wanted or not
NESTEDThe stack has a parent stackJudge it by its root stack, not on its own
STACKSETThe name starts with StackSet-, the naming pattern StackSets typically use for the stacks they create in target accountsManaged 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

Early access, from November 2026

Stackmuster is not available yet. One email when it opens.

One email when early access opens, around November 2026. Nothing else, and no sharing with anyone.