Detect CloudFormation Drift Across All Stacks

Architectural blueprint drawings spread out on a desk with a ruler and pencil

Photo by Amsterdam City Archives on Unsplash

To detect CloudFormation drift across all stacks, list the stacks with ListStacks, start DetectStackDrift on each one, poll DescribeStackDriftDetectionStatus until it finishes, then read DescribeStackResourceDrifts for the MODIFIED and DELETED resources. The script below does this in every Region you name, a few stacks at a time, and never changes a stack.

Drift is what happens when someone fixes production in the console at 2 a.m. and the template never hears about it. The next stack update then either reverts the fix or fails, and nobody knows why. The console runs drift detection on one stack at a time, which is fine for the stack you’re migrating and useless for an account with 200 of them.

This example is for platform engineers and reviewers who need to detect CloudFormation drift account-wide, on a schedule, with a CSV they can hand to stack owners. The guides on how to migrate a Terraform stack to CloudFormation with resource import and move a Terraform stack into AWS CDK TypeScript run drift detection on the one stack being moved; this script covers everything else.

What does drift detection check, and what does it miss?

CloudFormation compares the property values your template and parameters set with what the resource looks like now. A resource drifts when a property changed or disappeared, or when the resource was deleted. A stack is DRIFTED as soon as one resource is. The gaps matter as much as the results:

Blind spot What happens What to do
Unsupported resource types They get NOT_CHECKED, and a stack made only of them still reports IN_SYNC The script counts NOT_CHECKED resources per stack so a clean result isn’t overread
Properties you didn’t set Default values aren’t compared, so a changed default never shows up Set the properties you care about explicitly, even to the default
Nested stacks A parent’s check doesn’t cover its children The script checks every stack, nested ones included
Values the service never returns Passwords and a Lambda function’s code can’t be compared Check those by other means
KMSKeyId properties Never checked, because one key can have many aliases Review key settings separately
Attachments across stacks A security group rule added from another stack can look like drift Keep attachments in the same stack as the resource

Drift detection only runs on stacks in CREATE_COMPLETE, UPDATE_COMPLETE, UPDATE_ROLLBACK_COMPLETE or UPDATE_ROLLBACK_FAILED, so the script lists only those. The CloudFormation drift detection documentation links the list of resource types that support it, which grows over time. The underlying idea is older than CloudFormation; Martin Fowler’s note on configuration synchronization and drift explains why hand-edited servers drift apart over time. Drift detection also says nothing about what’s inside the container images a template deploys; the script to find ECR repositories without image scanning covers that part of the supply chain.

What does the script do?

  1. Lists the stackspaginateListStacks with a StackStatusFilter of the four statuses drift detection accepts, optionally narrowed by a name prefix.
  2. Starts drift detection, a few stacks at a timeDetectStackDrift per stack, three in parallel by default. Only one drift check can run on a given stack at once, and the SDK client gets extra retry attempts for API throttling.
  3. Polls with backoffDescribeStackDriftDetectionStatus every 2 seconds, doubling to 30, with jitter, and gives up after 15 minutes. A check can take several minutes on a large stack.
  4. Lists what changedpaginateDescribeStackResourceDrifts filtered to MODIFIED and DELETED, with the first three PropertyDifferences per resource.
  5. Counts the blind spotspaginateListStackResources reports each resource’s drift status; NOT_CHECKED ones are counted per stack.
  6. Can reuse recent checksWith --reuse-hours 24, a stack checked in the last day isn’t checked again; its stored results are read instead.

Prerequisites

  • Node.js 18 or later, npm and tsx, plus the @aws-sdk/client-cloudformation package.
  • An AWS profile the SDK can resolve in each Region you scan.
  • Read access to the resources inside your stacks, not just to CloudFormation (next section).

Which IAM permissions does it need?

Drift detection reads each resource with your credentials. A stack with an AWS::EC2::Instance needs ec2:DescribeInstances, a stack with a queue needs SQS read access, and so on. Attach AWS’s ReadOnlyAccess managed policy for that part, plus this policy for CloudFormation itself:

cfn-drift-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListAndPollDrift",
      "Effect": "Allow",
      "Action": [
        "cloudformation:ListStacks",
        "cloudformation:DescribeStackDriftDetectionStatus",
        "cloudformation:BatchDescribeTypeConfigurations"
      ],
      "Resource": "*"
    },
    {
      "Sid": "DetectDriftOnStacks",
      "Effect": "Allow",
      "Action": [
        "cloudformation:DetectStackDrift",
        "cloudformation:DetectStackResourceDrift",
        "cloudformation:DescribeStackResourceDrifts",
        "cloudformation:ListStackResources"
      ],
      "Resource": "arn:aws:cloudformation:*:123456789012:stack/*/*"
    }
  ]
}

Replace the account ID. The IAM half of the audit is where most UNKNOWN results come from, so if a stack fails with an access error, the steps to troubleshoot AWS IAM access denied errors show which read action is missing. The free IAM policy generator for TypeScript AWS SDK code drafts the CloudFormation part from the script, and the checklist to review an IAM policy for least privilege covers what to tighten.

The script to detect CloudFormation drift in every stack

detect-cloudformation-drift-all-stacks.ts

// detect-cloudformation-drift-all-stacks.ts
// Runs CloudFormation drift detection on every stack in the chosen Regions (nested stacks included,
// because a parent's check doesn't cover them), then lists each MODIFIED or DELETED resource with the
// property paths that changed, and counts resources that drift detection can't check (NOT_CHECKED).
// Drift detection reads resource configuration; it never changes a stack or a resource.
// Usage:
//   npx tsx detect-cloudformation-drift-all-stacks.ts [--regions us-east-1,eu-west-1] [--concurrency 3]
//     [--reuse-hours 24] [--stack-prefix prod-] [--csv drift.csv]
import { writeFileSync } from "node:fs";
import { setTimeout as sleep } from "node:timers/promises";
import {
  CloudFormationClient,
  DescribeStackDriftDetectionStatusCommand,
  DetectStackDriftCommand,
  paginateDescribeStackResourceDrifts,
  paginateListStackResources,
  paginateListStacks,
  type StackStatus,
  type StackSummary,
} 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 regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const concurrency = Math.max(1, Number(flag("--concurrency") ?? "3"));
const reuseHours = Number(flag("--reuse-hours") ?? "0"); // > 0: reuse a drift check newer than this instead of starting one
const prefix = flag("--stack-prefix") ?? "";
const csvPath = flag("--csv");

// The only stack statuses drift detection accepts.
const DRIFT_STATUSES: StackStatus[] = ["CREATE_COMPLETE", "UPDATE_COMPLETE", "UPDATE_ROLLBACK_COMPLETE", "UPDATE_ROLLBACK_FAILED"];
const POLL_TIMEOUT_MS = 15 * 60 * 1000;

interface StackRow {
  Region: string;
  Stack: string;
  Nested: string;
  Drift: string; // IN_SYNC, DRIFTED, UNKNOWN or ERROR
  Drifted: number;
  NotChecked: number;
  CheckedAt: string;
  Note: string;
}
interface ResourceRow {
  Region: string;
  Stack: string;
  LogicalId: string;
  Type: string;
  Status: string; // MODIFIED or DELETED
  Changes: string;
}

async function waitForDetection(cfn: CloudFormationClient, id: string) {
  const started = Date.now();
  let delay = 2000;
  for (;;) {
    const s = await cfn.send(new DescribeStackDriftDetectionStatusCommand({ StackDriftDetectionId: id }));
    if (s.DetectionStatus !== "DETECTION_IN_PROGRESS") return s;
    if (Date.now() - started > POLL_TIMEOUT_MS) throw new Error(`drift detection ${id} still running after 15 minutes`);
    await sleep(delay + Math.random() * 1000); // jitter, so parallel stacks don't poll in lockstep
    delay = Math.min(delay * 2, 30_000);
  }
}

async function checkStack(cfn: CloudFormationClient, region: string, stack: StackSummary, resources: ResourceRow[]): Promise<StackRow> {
  const name = stack.StackName ?? "";
  const row: StackRow = { Region: region, Stack: name, Nested: stack.ParentId ? "yes" : "", Drift: "", Drifted: 0, NotChecked: 0, CheckedAt: "", Note: "" };
  try {
    const last = stack.DriftInformation?.LastCheckTimestamp;
    const fresh = reuseHours > 0 && last !== undefined && Date.now() - last.getTime() < reuseHours * 3_600_000;
    if (fresh) {
      row.Drift = stack.DriftInformation?.StackDriftStatus ?? "NOT_CHECKED";
      row.CheckedAt = last.toISOString();
      row.Note = "reused last check";
    } else {
      const { StackDriftDetectionId } = await cfn.send(new DetectStackDriftCommand({ StackName: stack.StackId ?? name }));
      if (!StackDriftDetectionId) throw new Error("no drift detection ID returned");
      const status = await waitForDetection(cfn, StackDriftDetectionId);
      row.Drift = status.StackDriftStatus ?? "UNKNOWN";
      row.CheckedAt = status.Timestamp?.toISOString() ?? "";
      if (status.DetectionStatus === "DETECTION_FAILED") row.Note = (status.DetectionStatusReason ?? "detection failed").slice(0, 80);
    }

    // What drifted, and how. Resources that were never checked aren't returned here.
    for await (const page of paginateDescribeStackResourceDrifts(
      { client: cfn },
      { StackName: stack.StackId ?? name, StackResourceDriftStatusFilters: ["MODIFIED", "DELETED"], MaxResults: 100 },
    )) {
      for (const d of page.StackResourceDrifts ?? []) {
        row.Drifted++;
        const diffs = (d.PropertyDifferences ?? []).map((p) => `${p.PropertyPath} ${p.DifferenceType}`);
        resources.push({
          Region: region,
          Stack: name,
          LogicalId: d.LogicalResourceId ?? "",
          Type: d.ResourceType ?? "",
          Status: d.StackResourceDriftStatus ?? "",
          Changes: diffs.slice(0, 3).join("; ") + (diffs.length > 3 ? ` (+${diffs.length - 3} more)` : ""),
        });
      }
    }

    // Resource types that drift detection doesn't support stay NOT_CHECKED: count them so IN_SYNC isn't overread.
    for await (const page of paginateListStackResources({ client: cfn }, { StackName: stack.StackId ?? name })) {
      for (const r of page.StackResourceSummaries ?? []) {
        if (r.DriftInformation?.StackResourceDriftStatus === "NOT_CHECKED") row.NotChecked++;
      }
    }
  } catch (err) {
    row.Drift = "ERROR";
    row.Note = (err instanceof Error ? `${err.name}: ${err.message}` : String(err)).slice(0, 80);
  }
  return row;
}

async function runPool<T, R>(items: T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> {
  const results: R[] = [];
  let next = 0;
  const lanes = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (next < items.length) {
      const item = items[next++] as T;
      results.push(await worker(item));
    }
  });
  await Promise.all(lanes);
  return results;
}

function toCsv<T extends object>(rows: T[]): string {
  if (!rows.length) return "";
  const cols = Object.keys(rows[0] as object) as (keyof T)[];
  const cell = (v: unknown) => `"${String(v).replace(/"/g, '""')}"`;
  return [cols.join(","), ...rows.map((r) => cols.map((c) => cell(r[c])).join(","))].join("\n") + "\n";
}

async function main(): Promise<void> {
  const stacks: StackRow[] = [];
  const resources: ResourceRow[] = [];
  for (const region of regions) {
    const cfn = new CloudFormationClient({ region, maxAttempts: 8 }); // extra retries absorb API throttling
    const summaries: StackSummary[] = [];
    for await (const page of paginateListStacks({ client: cfn }, { StackStatusFilter: DRIFT_STATUSES })) {
      for (const s of page.StackSummaries ?? []) if ((s.StackName ?? "").startsWith(prefix)) summaries.push(s);
    }
    console.log(`${region}: checking ${summaries.length} stacks, ${concurrency} at a time`);
    stacks.push(...(await runPool(summaries, concurrency, (s) => checkStack(cfn, region, s, resources))));
  }

  stacks.sort((a, b) => a.Drift.localeCompare(b.Drift) || a.Stack.localeCompare(b.Stack));
  console.table(stacks);
  if (resources.length) console.table(resources);

  const count = (d: string) => stacks.filter((s) => s.Drift === d).length;
  console.log(
    `${stacks.length} stacks: ${count("DRIFTED")} drifted, ${count("IN_SYNC")} in sync, ` +
      `${count("UNKNOWN")} unknown, ${count("ERROR")} errors; ${resources.length} drifted resources`,
  );
  const unchecked = stacks.reduce((n, s) => n + s.NotChecked, 0);
  if (unchecked) console.log(`${unchecked} resources were NOT_CHECKED (resource type doesn't support drift detection)`);
  if (csvPath) {
    writeFileSync(csvPath, toCsv(stacks));
    writeFileSync(csvPath.replace(/\.csv$/, "") + "-resources.csv", toCsv(resources));
    console.log(`Wrote ${csvPath} and the per-resource CSV next to it`);
  }
}

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

How do you run it?

Terminal

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

# Every stack in two Regions, three checks at a time, with CSVs for stack owners
AWS_PROFILE=audit npx tsx detect-cloudformation-drift-all-stacks.ts --regions us-east-1,eu-west-1 --csv drift.csv

# Nightly run: only re-check stacks whose last check is older than 24 hours
AWS_PROFILE=audit npx tsx detect-cloudformation-drift-all-stacks.ts --reuse-hours 24 --stack-prefix prod-

The run takes roughly as long as your largest stacks, because each check is asynchronous on the CloudFormation side. Raise --concurrency carefully: more parallel checks mean more polling calls, and CloudFormation throttles API calls per account. The SDK retries throttled calls; the guide to configure retries and timeouts in AWS SDK for JavaScript v3 explains how maxAttempts backs off.

Sample output

Output

us-east-1: checking 4 stacks, 3 at a time
┌─────────┬─────────────┬───────────────────┬────────┬───────────┬─────────┬────────────┬──────────────────────────┬──────┐
│ (index) │ Region      │ Stack             │ Nested │ Drift     │ Drifted │ NotChecked │ CheckedAt                │ Note │
├─────────┼─────────────┼───────────────────┼────────┼───────────┼─────────┼────────────┼──────────────────────────┼──────┤
│ 0       │ 'us-east-1' │ 'prod-api'        │ ''     │ 'DRIFTED' │ 2       │ 1          │ '2027-04-12T08:14:03Z'   │ ''   │
│ 1       │ 'us-east-1' │ 'prod-api-Queues' │ 'yes'  │ 'DRIFTED' │ 1       │ 0          │ '2027-04-12T08:14:05Z'   │ ''   │
│ 2       │ 'us-east-1' │ 'prod-network'    │ ''     │ 'IN_SYNC' │ 0       │ 0          │ '2027-04-12T08:14:04Z'   │ ''   │
│ 3       │ 'us-east-1' │ 'prod-dns'        │ ''     │ 'IN_SYNC' │ 0       │ 6          │ '2027-04-12T08:14:09Z'   │ ''   │
└─────────┴─────────────┴───────────────────┴────────┴───────────┴─────────┴────────────┴──────────────────────────┴──────┘
┌─────────┬─────────────┬───────────────────┬──────────────┬───────────────────────────┬────────────┬──────────────────────────────────────────────────────────────┐
│ (index) │ Region      │ Stack             │ LogicalId    │ Type                      │ Status     │ Changes                                                      │
├─────────┼─────────────┼───────────────────┼──────────────┼───────────────────────────┼────────────┼──────────────────────────────────────────────────────────────┤
│ 0       │ 'us-east-1' │ 'prod-api'        │ 'ApiSg'      │ 'AWS::EC2::SecurityGroup' │ 'MODIFIED' │ '/SecurityGroupIngress/2 ADD'                                │
│ 1       │ 'us-east-1' │ 'prod-api'        │ 'LogsBucket' │ 'AWS::S3::Bucket'         │ 'DELETED'  │ ''                                                           │
│ 2       │ 'us-east-1' │ 'prod-api-Queues' │ 'OrdersQ'    │ 'AWS::SQS::Queue'         │ 'MODIFIED' │ '/VisibilityTimeout NOT_EQUAL; /RedrivePolicy/maxReceiveCount NOT_EQUAL' │
└─────────┴─────────────┴───────────────────┴──────────────┴───────────────────────────┴────────────┴──────────────────────────────────────────────────────────────┘
4 stacks: 2 drifted, 2 in sync, 0 unknown, 0 errors; 3 drifted resources
7 resources were NOT_CHECKED (resource type doesn't support drift detection)

Names and times are illustrative. The added ingress rule on ApiSg is the classic console fix, and worth checking against the script that finds security groups open to the internet on common ports. prod-dns is IN_SYNC with six resources nobody checked, which is why that column exists.

What should you do with drifted resources?

Each drifted resource has two fixes: change the resource back to match the template, or change the template to match the resource. Which one is right depends on whether the manual change was a mistake or a fix. For a deliberate change, update the template and deploy it so the next update doesn’t undo it. For a DELETED resource, deploy the template to recreate it, or remove it from the template. CloudFormation can also resolve drift by importing the resource again.

To stop drift coming back, find who made the change. The checks to confirm CloudTrail is logging in every Region and to check AWS Config is recording in all Regions make sure you can answer that question. If you’re weighing whether to keep the stack in CloudFormation at all, the comparison of AWS CDK vs Terraform for existing stacks covers the trade-offs, and the CloudFormation YAML to Terraform converter drafts the HCL for review.

Troubleshooting

  • UNKNOWN drift or DETECTION_FAILED. CloudFormation couldn’t read at least one resource. The Note column carries DetectionStatusReason; usually a missing read permission for that resource type. Results for the other resources are still returned.
  • An error when starting a check on a busy stack. Only one drift detection can run on a stack at a time, so a check started by someone else, or by an earlier run, blocks a new one. Wait and re-run, or use --reuse-hours.
  • A throttling error after all retries. Lower --concurrency to 1 or 2 for large accounts.
  • Drift reported on tags or array items you never touched. Services add default values to some array properties, and equal-but-different values (1024 MB versus 1 GB) count as drift. Make the template match exactly what the service returns.

Ask ChatWithCloud instead

For one stack in one Region, ask ChatWithCloud “Which resources in the prod-api stack have drifted, and what changed?” It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and summarizes the result; the page on how ChatWithCloud runs AWS SDK code on your machine walks through each step. It works with one profile and one Region per session and runs generated code without a confirmation step, so connect ChatWithCloud to a read-only AWS profile for audits. For the whole account on a schedule, keep the script.

Frequently asked questions

Does drift detection change my stack or resources?

No. It reads each resource’s current configuration and compares it with the template. It doesn’t update the stack, revert changes or touch the resources.

How do I detect CloudFormation drift with the AWS CLI?

Run aws cloudformation detect-stack-drift --stack-name my-stack, pass the returned ID to describe-stack-drift-detection-status until it’s complete, then run describe-stack-resource-drifts --stack-name my-stack --stack-resource-drift-status-filters MODIFIED DELETED.

Why is my stack IN_SYNC when I know something changed?

The changed property may not be set in the template, the resource type may not support drift detection, or the change is in a nested stack. Check the NotChecked count and the nested stacks.

Can I detect drift on StackSets?

Yes, StackSets have their own drift detection that reports per stack instance. This script covers regular stacks, including the stack instances StackSets deploy into the account you scan.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud