Find CloudFormation Stacks Without Termination Protection

Neatly bundled network cables running into a server rack

Photo by Sergei Starostin on Pexels

CloudFormation termination protection is off by default. To find stacks without it, call DescribeStacks and check EnableTerminationProtection on each root stack, skipping nested stacks (those with ParentId or RootId), which inherit the setting from their root. Turn it on with UpdateTerminationProtection. It blocks stack deletion only, not updates that remove resources.

Deleting a CloudFormation stack deletes everything in it that isn’t marked to be kept: the database, the buckets it can empty, the VPC everything else runs in. One wrong stack name in a delete-stack call or a cleanup script is enough.

This example is for teams that run production on CloudFormation, CDK or SAM. The script lists the root stacks in a Region that don’t have CloudFormation termination protection, counts their resources and stateful resources, and checks how many resources the template marks to be retained. With --apply it enables protection on the stacks you choose by name or tag.

What does CloudFormation termination protection block?

With termination protection on, a DeleteStack call fails and the stack, including its status, stays unchanged. Tools that delete stacks through that API fail the same way. What it doesn’t do matters just as much:

  • Updates still delete resources. If a template change removes a resource, CloudFormation deletes it. Protection only applies to deleting the stack.
  • Nested stacks follow the root. You can’t set it on a nested stack directly. A direct delete of a nested stack under a protected root fails, but a stack update that removes the nested stack still deletes it.
  • Anyone with cloudformation:UpdateTerminationProtection can turn it off. It’s a guard against accidents, not against someone with admin rights.
  • It isn’t rollback protection. Disabling rollback is a separate setting for failed stack creation.

What’s the other safeguard: DeletionPolicy?

Termination protection guards the whole stack. DeletionPolicy guards individual resources. Set to Retain (or RetainExceptOnCreate), CloudFormation leaves the resource in place when the stack is deleted or the resource is removed from the template; Snapshot keeps a final snapshot for resource types that support it. UpdateReplacePolicy does the same when an update replaces the resource.

They cover different failures, so production stacks usually want both. The script shows the gap: how many stateful resources a stack has (databases, tables, buckets, file systems, volumes, keys, streams) next to how many DeletionPolicy entries its processed template contains. A stack with more stateful resources than retained ones is flagged first. It’s a count, not a match of resource to policy, so check the template for anything flagged.

Drift is the third piece. A resource changed outside CloudFormation won’t match its template when you rely on either safeguard; the example to detect CloudFormation drift across all stacks finds those.

What does the script do?

  1. Lists stackspaginateDescribeStacks returns every live stack with EnableTerminationProtection, ParentId, RootId, CreationTime and tags.
  2. Skips what it can’t changeNested stacks and stacks being deleted are left out; protected root stacks are counted.
  3. Sizes each unprotected stackpaginateListStackResources counts resources and stateful types; GetTemplate with TemplateStage: "Processed" returns the template after transforms such as SAM, where it counts DeletionPolicy entries.
  4. Enables protection, if askedWith --apply, UpdateTerminationProtection runs for stacks named in --names or carrying the --tag you pass.

Prerequisites

Which IAM permissions does it need?

termination-protection-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadStacks",
      "Effect": "Allow",
      "Action": ["cloudformation:DescribeStacks", "cloudformation:ListStackResources", "cloudformation:GetTemplate"],
      "Resource": "arn:aws:cloudformation:*:111122223333:stack/*/*"
    },
    {
      "Sid": "EnableProtectionOnlyWithApply",
      "Effect": "Allow",
      "Action": "cloudformation:UpdateTerminationProtection",
      "Resource": "arn:aws:cloudformation:*:111122223333:stack/*/*"
    }
  ]
}

Remove the second statement for a report-only role. The same action also turns protection off, so be careful who else has it. The OWASP Infrastructure as Code Security Cheat Sheet recommends limiting IaC users’ permissions to what their tasks need, and UpdateTerminationProtection plus DeleteStack on production stacks is a good place to start. The IAM policy generator for TypeScript drafts a policy from the script, and the checklist to review a generated IAM policy for least privilege covers narrowing it.

The script to find stacks without CloudFormation termination protection

find-cloudformation-stacks-without-termination-protection.ts

// find-cloudformation-stacks-without-termination-protection.ts
// Lists root CloudFormation stacks in a Region that don't have termination protection, with their age,
// resource count, stateful resources (databases, buckets, file systems...) and how many resources the
// processed template marks with a DeletionPolicy of Retain, RetainExceptOnCreate or Snapshot.
// Nested stacks are skipped: they inherit the setting from their root stack.
// --apply turns termination protection ON for the stacks you pick by --names or by --tag key=value.
// Usage:
//   npx tsx find-cloudformation-stacks-without-termination-protection.ts [--region eu-west-1]
//   npx tsx find-cloudformation-stacks-without-termination-protection.ts --apply --tag env=prod
//   npx tsx find-cloudformation-stacks-without-termination-protection.ts --apply --names billing-api,shared-vpc
import {
  CloudFormationClient,
  GetTemplateCommand,
  UpdateTerminationProtectionCommand,
  paginateDescribeStacks,
  paginateListStackResources,
  type Stack,
} from "@aws-sdk/client-cloudformation";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const region = flag("--region") ?? process.env.AWS_REGION ?? "us-east-1";
const apply = args.includes("--apply");
const names = new Set((flag("--names") ?? "").split(",").map((s) => s.trim()).filter(Boolean));
const [tagKey, tagValue] = (flag("--tag") ?? "").split("=");
if (apply && !names.size && !tagKey) {
  console.error("--apply needs --names a,b or --tag key=value");
  process.exit(1);
}

const cfn = new CloudFormationClient({ region });
const DAY = 86_400_000;
const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));

// Resource types that hold data you can't get back by redeploying the template
const STATEFUL = new Set([
  "AWS::RDS::DBInstance",
  "AWS::RDS::DBCluster",
  "AWS::DynamoDB::Table",
  "AWS::DynamoDB::GlobalTable",
  "AWS::S3::Bucket",
  "AWS::EFS::FileSystem",
  "AWS::EC2::Volume",
  "AWS::ElastiCache::ReplicationGroup",
  "AWS::OpenSearchService::Domain",
  "AWS::Cognito::UserPool",
  "AWS::KMS::Key",
  "AWS::Kinesis::Stream",
]);

async function inspect(stackName: string): Promise<{ total: number; stateful: number; retained: number }> {
  let total = 0;
  let stateful = 0;
  for await (const page of paginateListStackResources({ client: cfn }, { StackName: stackName })) {
    for (const r of page.StackResourceSummaries ?? []) {
      if (r.ResourceStatus === "DELETE_COMPLETE") continue;
      total++;
      if (r.ResourceType && STATEFUL.has(r.ResourceType)) stateful++;
    }
  }
  // Counts DeletionPolicy lines in the processed template (JSON or YAML) - an approximation, not a parser
  const tpl = await cfn.send(new GetTemplateCommand({ StackName: stackName, TemplateStage: "Processed" }));
  const body = tpl.TemplateBody ?? "";
  const retained = [...body.matchAll(/"?DeletionPolicy"?\s*:\s*"?(Retain|RetainExceptOnCreate|Snapshot)\b/g)].length;
  return { total, stateful, retained };
}

const selected = (s: Stack): boolean =>
  names.has(s.StackName ?? "") || (!!tagKey && (s.Tags ?? []).some((t) => t.Key === tagKey && t.Value === tagValue));

async function main(): Promise<void> {
  const rows: Record<string, string | number>[] = [];
  const toProtect: string[] = [];
  let protectedCount = 0;
  let nested = 0;
  for await (const page of paginateDescribeStacks({ client: cfn }, {})) {
    for (const s of page.Stacks ?? []) {
      if (!s.StackName || s.StackStatus === "DELETE_COMPLETE" || s.StackStatus === "DELETE_IN_PROGRESS") continue;
      if (s.ParentId || s.RootId) {
        nested++; // termination protection is set on the root stack only
        continue;
      }
      if (s.EnableTerminationProtection) {
        protectedCount++;
        continue;
      }
      const ageDays = s.CreationTime ? Math.floor((Date.now() - s.CreationTime.getTime()) / DAY) : 0;
      let detail = { total: 0, stateful: 0, retained: 0 };
      try {
        detail = await inspect(s.StackName);
      } catch (err) {
        console.error(`Could not inspect ${s.StackName}: ${errText(err)}`);
      }
      const env = (s.Tags ?? []).find((t) => t.Key === (tagKey || "env"))?.Value ?? "-";
      rows.push({
        Stack: s.StackName,
        Status: s.StackStatus ?? "?",
        AgeDays: ageDays,
        Tag: env,
        Resources: detail.total,
        Stateful: detail.stateful,
        RetainedInTemplate: detail.retained,
        Finding: detail.stateful > detail.retained ? "UNPROTECTED: stateful resources without Retain" : "unprotected",
      });
      if (selected(s)) toProtect.push(s.StackName);
    }
  }
  rows.sort((a, b) => Number(b.Stateful) - Number(a.Stateful));
  console.table(rows);
  console.log(
    `${region}: ${rows.length} root stacks without termination protection, ${protectedCount} with it, ${nested} nested skipped.`,
  );

  if (!apply) {
    console.log("Report only: nothing was changed. Use --apply with --names or --tag to enable protection.");
    return;
  }
  for (const name of toProtect) {
    try {
      await cfn.send(new UpdateTerminationProtectionCommand({ StackName: name, EnableTerminationProtection: true }));
      console.log(`Enabled termination protection on ${name}`);
    } catch (err) {
      console.error(`Could not update ${name}: ${errText(err)}`);
      process.exitCode = 1;
    }
  }
}

main().catch((err) => {
  console.error(errText(err));
  process.exit(1);
});

Edit the STATEFUL set to match what you run. The DeletionPolicy count uses a regular expression that works on JSON and YAML templates, which avoids a YAML parser dependency but can miscount unusual formatting.

How do you run it?

Terminal

npm install @aws-sdk/client-cloudformation
npm install --save-dev tsx typescript @types/node

# Report only
AWS_PROFILE=readonly npx tsx find-cloudformation-stacks-without-termination-protection.ts --region eu-west-1

# Enable protection on every unprotected root stack tagged env=prod
AWS_PROFILE=platform-admin npx tsx find-cloudformation-stacks-without-termination-protection.ts \
  --region eu-west-1 --apply --tag env=prod

Sample output

Output (report only)

┌─────────┬───────────────────┬───────────────────┬─────────┬────────┬───────────┬──────────┬────────────────────┬──────────────────────────────────────────────────┐
│ (index) │ Stack             │ Status            │ AgeDays │ Tag    │ Resources │ Stateful │ RetainedInTemplate │ Finding                                          │
├─────────┼───────────────────┼───────────────────┼─────────┼────────┼───────────┼──────────┼────────────────────┼──────────────────────────────────────────────────┤
│ 0       │ 'billing-api'     │ 'UPDATE_COMPLETE' │ 612     │ 'prod' │ 6         │ 3        │ 1                  │ 'UNPROTECTED: stateful resources without Retain' │
│ 1       │ 'analytics-lake'  │ 'UPDATE_COMPLETE' │ 420     │ 'prod' │ 3         │ 2        │ 2                  │ 'unprotected'                                    │
│ 2       │ 'pr-1432-preview' │ 'UPDATE_COMPLETE' │ 3       │ 'dev'  │ 2         │ 1        │ 0                  │ 'UNPROTECTED: stateful resources without Retain' │
│ 3       │ 'shared-vpc'      │ 'UPDATE_COMPLETE' │ 1040    │ 'prod' │ 4         │ 0        │ 0                  │ 'unprotected'                                    │
└─────────┴───────────────────┴───────────────────┴─────────┴────────┴───────────┴──────────┴────────────────────┴──────────────────────────────────────────────────┘
eu-west-1: 4 root stacks without termination protection, 2 with it, 1 nested skipped.
Report only: nothing was changed. Use --apply with --names or --tag to enable protection.

Stack names are illustrative. billing-api holds an Aurora cluster, a DB instance and a DynamoDB table, and its template retains only one of them, so a stray delete would take the rest with it. analytics-lake retains both buckets, which is why it’s lower on the list, but deleting it would still remove everything else. shared-vpc has no stateful resources, yet other stacks depend on it. pr-1432-preview is a 3-day-old preview environment that’s meant to be deleted. Running with --apply --tag env=prod protects the three production stacks and leaves the preview alone.

How do you keep new stacks protected?

  • CDK: set terminationProtection: true in the stack props for production stacks.
  • CLI and pipelines: pass --enable-termination-protection to aws cloudformation create-stack, or run aws cloudformation update-termination-protection --enable-termination-protection --stack-name billing-api after the first deploy.
  • Templates: add DeletionPolicy: Retain or Snapshot to every stateful resource, and make sure backups exist regardless. The AWS Backup coverage report example shows which resources nothing backs up, and the one to find RDS databases without deletion protection adds a guard at the database level.

If you’re moving stacks to another tool, protection matters during the move too: the guides to import CloudFormation resources into Terraform safely and choose between AWS CDK and Terraform for existing stacks both rely on retaining resources while the old stack goes away.

Troubleshooting

  • UpdateTerminationProtection fails for one stack. Check whether you passed a nested stack name or a stack in DELETE_IN_PROGRESS. Set protection on the root stack instead.
  • You need to delete a protected stack. Turn protection off with --no-enable-termination-protection, delete, and treat that as a change that needs review.
  • AccessDenied on GetTemplate. The row still appears with zero counts. The steps to troubleshoot AWS IAM access denied errors show which policy is missing the action.
  • Too many stacks to inspect. The script calls two APIs per unprotected stack. On accounts with hundreds of preview stacks, filter by tag in your own copy before inspecting.

Ask ChatWithCloud instead

For a quick list, ask ChatWithCloud “Which root CloudFormation stacks in eu-west-1 don’t have termination protection?” It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and summarizes the answer. Because it runs changes without a confirmation step, keep the --apply step in the script, and use a read-only profile as the ChatWithCloud security page advises. The guide to troubleshoot AWS infrastructure with an AI CLI shows the kind of follow-up questions it can answer about live infrastructure.

Frequently asked questions

Is CloudFormation termination protection enabled by default?

No. It’s disabled by default. You can enable it when you create a stack or later, on any stack that isn’t being deleted or already deleted.

Can I enable termination protection on a nested stack?

Not directly. Nested stacks take the setting from their root stack, so enable it there.

Does termination protection stop resources being deleted by a stack update?

No. It only blocks deleting the stack. Use DeletionPolicy and UpdateReplacePolicy on the resources, or a stack policy, to guard against updates.

Which permission turns termination protection on or off?

cloudformation:UpdateTerminationProtection. The same action does both, so limit who has it on production stacks.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud