Deleting CloudFormation stacks when the template is removed from Git
You delete a CloudFormation template from your repository, merge the pull request, and the pipeline goes green. The stack it created is still running.
Nothing failed. Nothing warned you. The repository now says the service is gone, and the AWS account says otherwise. Do that a few dozen times over a couple of years and you end up with an account full of stacks nobody can explain: some costing money, some holding open security groups or IAM roles, and some that everyone is afraid to touch because nobody remembers what depends on them.
This guide covers why it happens, what makes deleting a stack riskier than it looks, and three practical ways to keep the account in step with the repository.
Why removing a template doesn’t remove the stack
Almost every way of deploying CloudFormation from Git is built around one question: what changed in this template? A pipeline sees a modified file and runs a create-or-update. That works well for the first two parts of a stack’s life. The third part, deletion, isn’t a change to a template. It’s the template no longer existing, and there is nothing left in the repository to trigger anything.
The common tools all behave this way:
- CodePipeline’s CloudFormation action in
CREATE_UPDATEmode creates or updates the stack it’s pointed at. It has a separateDELETE_ONLYmode, but something has to decide to run it, for a specific stack name. - CloudFormation Git sync keeps one stack in step with one deployment file. Removing the files from the repository doesn’t remove the deployed stack.
- GitHub Actions or GitLab CI jobs that call
aws cloudformation deployonly run for templates that exist. A deleted file simply stops being deployed.
So deletion is left to a person remembering to do it. Usually nobody does, because the pull request that removed the template looked finished.
Why deleting a stack is riskier than it looks
Before automating deletion, it’s worth knowing what can go wrong, because a stack delete is the most destructive thing CloudFormation does.
Data goes with the stack. Deleting a stack deletes its resources unless a resource says otherwise. A DynamoDB table, a database or a log group defined in the template is deleted along with everything else.
DeletionPolicy changes the outcome. Resources marked DeletionPolicy: Retain are left behind when the stack is deleted. They keep running and keep costing money, but CloudFormation no longer manages them, so they’re harder to find later. Snapshot takes a final backup first, for the resource types that support it. Some resource types default to taking a snapshot even if the template doesn’t say so. Check your templates before relying on either behaviour.
Some deletes fail part-way. A stack ends up in DELETE_FAILED when, for example:
- an S3 bucket in the stack still contains objects;
- another stack imports one of its outputs with
Fn::ImportValue(CloudFormation won’t delete an export that’s in use); - a resource was changed or removed outside CloudFormation;
- the stack has termination protection turned on.
A failed delete leaves a half-removed stack that someone has to finish by hand.
Renames look like deletions. If your pipeline maps file names to stack names, renaming orders-api.yaml to orders-service.yaml means one stack disappears from the repository and a new one appears. Handled naively, that deletes the old stack, data included, and creates a fresh, empty one. Decide up front how renames should behave.
None of this means you shouldn’t delete stacks automatically. It means the deletion step needs more guardrails than the deploy step.
Option 1: Find and delete leftover stacks by hand
If you just need to clean up once, start by listing what’s in the account and comparing it with the repository:
aws cloudformation list-stacks \
--stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE UPDATE_ROLLBACK_COMPLETE \
--query "StackSummaries[].[StackName,LastUpdatedTime,CreationTime]" \
--output table
That command covers one region. To check every region, flag stacks that haven’t changed in months and see their tags, use the script in Finding forgotten CloudFormation stacks in your AWS account.
For each stack you believe is orphaned:
- Check its outputs aren’t imported by other stacks:
aws cloudformation list-imports --export-name <export-name>for each export it defines. - Look for resources with
DeletionPolicy: Retainor data you need to keep, and back up anything that matters. - Empty any S3 buckets the stack owns.
- Delete it and wait for the result:
aws cloudformation delete-stack --stack-name orders-api
aws cloudformation wait stack-delete-complete --stack-name orders-api
This works, but it relies on someone remembering to do it after every removal. In practice that’s the step that gets skipped.
Option 2: Delete stacks from your pipeline when a template is removed
If you already deploy from GitHub Actions, you can add a job that notices removed templates and deletes their stacks. It needs one convention to work: a reliable way to get from a template file to its stack name. The simplest is one template per file, with the stack named after the file:
stacks/
orders-api.yaml → stack "orders-api"
billing-worker.yaml → stack "billing-worker"
This workflow runs on pushes to main, finds templates deleted by the push, and deletes the matching stacks. It only deletes stacks tagged as managed by this repository, so a typo or a shared stack name can’t take out something the pipeline never created.
name: Delete stacks for removed templates
on:
push:
branches: [main]
paths: ["stacks/**"]
permissions:
id-token: write # for OIDC role assumption
contents: read
jobs:
delete-removed-stacks:
runs-on: ubuntu-latest
environment: production # add required reviewers here for a manual approval gate
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-cfn-deploy
aws-region: ap-southeast-2
- name: Find removed templates
id: removed
run: |
BEFORE="${{ github.event.before }}"
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
echo "names=" >> "$GITHUB_OUTPUT" # new branch: nothing to compare against
exit 0
fi
NAMES=$(git diff --name-only --diff-filter=D "$BEFORE" "${{ github.sha }}" -- 'stacks/*.yaml' \
| xargs -r -n1 basename | sed 's/\.yaml$//' | tr '\n' ' ')
echo "names=$NAMES" >> "$GITHUB_OUTPUT"
- name: Delete matching stacks
if: steps.removed.outputs.names != ''
run: |
for NAME in ${{ steps.removed.outputs.names }}; do
OWNER=$(aws cloudformation describe-stacks --stack-name "$NAME" \
--query "Stacks[0].Tags[?Key=='managed-by'].Value | [0]" \
--output text 2>/dev/null || echo "missing")
if [ "$OWNER" != "${{ github.repository }}" ]; then
echo "Skipping $NAME: not tagged managed-by=${{ github.repository }} (found: $OWNER)"
continue
fi
echo "Deleting $NAME"
aws cloudformation delete-stack --stack-name "$NAME"
aws cloudformation wait stack-delete-complete --stack-name "$NAME"
done
For the ownership check to work, your deploy job needs to tag every stack it creates, for example with --tags managed-by=<owner>/<repo> on aws cloudformation deploy.
A few things to know about this approach:
- It only sees removals in the push it runs for. If the workflow is disabled, fails, or someone deletes a template with a force-push, that removal is never processed. Over time the account drifts again, which is why a periodic check (Option 1 on a schedule) is still worth having.
- Git reports renames as renames, not deletions, so
--diff-filter=Dskips them. The old stack is left behind and a new one gets created. That’s the safer failure, but it’s still an orphan. wait stack-delete-completefails the job if the delete fails, which is what you want: aDELETE_FAILEDstack needs a human.- The
environmentline is your safety net. Add required reviewers to theproductionenvironment in GitHub and every deletion waits for an explicit approval.
The same idea works in CodePipeline with a DELETE_ONLY action. The catch is that CodePipeline actions are configured per stack, so you need something else to work out which stacks were removed and run the action for them.
Option 3: Expiry-based cleanup for temporary stacks
For short-lived stacks, such as a stack per pull request or a test environment, it can be simpler to delete by age than by repository state. Tag each stack with an expiry date when it’s created, and run a scheduled job, such as a Lambda function on an EventBridge schedule, that deletes stacks whose expiry has passed. That suits stacks that are meant to be temporary. It doesn’t suit long-lived infrastructure, where a stack should live exactly as long as its template does, however long that is.
A checklist before you automate deletion
- Tag ownership on every stack your pipeline creates, and refuse to delete anything without the tag.
- Decide how renames behave, and write it down.
- Audit
DeletionPolicyon stateful resources. AddRetainorSnapshotwhere losing data would hurt. - Watch for cross-stack exports. Delete consumers before producers.
- Turn on termination protection for stacks that should never be deleted by automation.
- Gate production deletions behind an approval step.
- Run a periodic comparison of stacks in the account against templates in the repository, to catch anything the event-driven path missed.
Where Stackmuster fits
We’re building Stackmuster because the scripts above are exactly what every team ends up writing, and maintaining, for themselves. Stackmuster is planned to scan your repositories for CloudFormation templates and treat each one as a deployment that handles the whole lifecycle: create, update, and deletion when the template is removed.
Security is part of the design too. Deploying CloudFormation normally needs credentials that are effectively full administrator, and home-grown scripts like the ones above almost always run that way: a role like github-cfn-deploy in Option 2 has to be able to do anything any template might ask for. Stackmuster will let you attach an IAM permissions boundary to the access it deploys with. That way you decide what it should never be able to do, whatever a template asks for, such as creating IAM users or access keys, or launching EC2 instances directly.
Early access opens in November 2026. If you’d rather not maintain your own version of Option 2, join the early access list.
If you’re here because AWS Proton reached end of support, our Proton migration guide covers what happens to your stacks and the options AWS recommends. And if you’re weighing up CloudFormation Git sync, see how it compares.
Sources
- AWS CloudFormation User Guide: How Git sync works with CloudFormation
- AWS CloudFormation User Guide: CloudFormation configuration properties reference (CodePipeline action modes)
- AWS CloudFormation User Guide: DeletionPolicy attribute
- AWS CLI reference: delete-stack, wait stack-delete-complete, list-imports
- GitHub: aws-actions/configure-aws-credentials