Find Unused CloudFront Distributions

An empty multi-lane highway at night lit by rows of orange street lights

Photo by Rhamely on Unsplash

Unused CloudFront distributions are distributions that served no viewer requests over a period such as 30 days. Find them with ListDistributions and the Requests metric in the AWS/CloudFront namespace (us-east-1, dimension Region=Global). Also check for S3 origins whose bucket was deleted, then disable the distribution before you delete it.

Campaign sites, old docs portals and one-off test setups leave CloudFront distributions behind. An idle distribution on pay-as-you-go pricing costs little on its own, but it can still carry monthly add-ons, keep DNS records and certificates alive, and point at an S3 bucket name that someone else could now register. This example is for platform and FinOps engineers who want a list of unused CloudFront distributions with the evidence to retire them.

The script is read-only by default. It never deletes; with --disable and --apply it only switches off the distributions you name, and only when they had zero requests.

What do unused CloudFront distributions cost?

On pay-as-you-go pricing, CloudFront bills for usage: data transfer, requests, invalidations past the free allowance, and edge functions. A distribution with no traffic produces almost none of that. The costs that keep running are the ones attached to it. As of September 2026, the AWS Price List files (CloudFront published 16 September 2026, AWS WAF published 14 September 2026) show:

Charge Price What the script checks
Dedicated-IP custom SSL $600.00 per month per certificate SSLSupportMethod is vip
AWS WAF web ACL $5.00 per web ACL per month plus $1.00 per rule, prorated hourly WebACLId is set
Flat-rate pricing plan Monthly plan fee (Premium starts at $1,000 per month) Not visible in ListDistributions; check the console

Worked example: a forgotten campaign distribution with a dedicated-IP certificate and a web ACL with 5 rules costs $600.00 + $5.00 + 5 × $1.00 = $610.00 a month, or $7,320 a year, while serving nothing. Two details change the cleanup plan. WAF bills per web ACL created, so deleting the distribution doesn’t stop the WAF charge until you delete the web ACL too. And CloudFront’s documentation says a disabled distribution on a flat-rate plan still incurs the plan charge; you must cancel the plan, which takes effect at the end of the billing cycle, before you can delete the distribution.

The FinOps Foundation’s usage optimization capability treats this kind of waste reduction as routine: find resources that were provisioned but are no longer used, remove them, then automate the cleanup so they don’t pile up again.

Why does a dangling S3 origin matter?

General purpose S3 bucket names are global within a partition. The S3 User Guide warns that after you delete a bucket, another AWS account can create a bucket with the same name and can therefore receive requests intended for the deleted one. If a CloudFront distribution still lists that bucket as its origin, whoever creates the bucket decides what your hostname serves.

The script compares each S3 origin with the buckets in your account. For names you don’t own, it sends HeadBucket: a 404 means the bucket doesn’t exist and the name can be taken, and anything else means the bucket exists in another account, which may be intended (a partner’s bucket) or may not. Fix a dangling origin before anything else, even on a distribution you plan to keep. The script to categorize S3 buckets by public or private access helps confirm how the origins you still own are exposed.

What does the script check?

  1. Lists distributionspaginateListDistributions on the CloudFront API, which is global.
  2. Reads trafficOne GetMetricData call per 250 distributions for daily Sum of Requests and BytesDownloaded over --days (default 30), in us-east-1 with DistributionId and Region=Global.
  3. Checks S3 originsParses REST and website endpoint origins, compares them with ListBuckets, and calls HeadBucket for the rest.
  4. Flags monthly add-onsDedicated-IP SSL, an attached web ACL and an Anycast static IP list.
  5. Disables only on requestReads the config and ETag with GetDistributionConfig, then sends UpdateDistribution with Enabled: false and IfMatch.

Prerequisites

Which IAM permissions does it need?

unused-cloudfront-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadDistributionsMetricsBuckets",
      "Effect": "Allow",
      "Action": [
        "cloudfront:ListDistributions",
        "cloudwatch:GetMetricData",
        "s3:ListAllMyBuckets"
      ],
      "Resource": "*"
    },
    {
      "Sid": "HeadOriginBuckets",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::*"
    },
    {
      "Sid": "DisableOnlyWithApply",
      "Effect": "Allow",
      "Action": [
        "cloudfront:GetDistributionConfig",
        "cloudfront:UpdateDistribution"
      ],
      "Resource": "arn:aws:cloudfront::123456789012:distribution/*"
    }
  ]
}

HeadBucket is authorized by s3:ListBucket. For buckets in other accounts you’ll get a 403 regardless, which the script reports as “other account”. Drop the last statement for a report-only role, and replace the account ID. To regenerate the policy after changes, use the free IAM policy generator for TypeScript code.

The script to find unused CloudFront distributions

find-unused-cloudfront-distributions.ts

// find-unused-cloudfront-distributions.ts
// Lists CloudFront distributions with their viewer requests and bytes downloaded over --days days,
// flags distributions with no requests, S3 origins whose bucket no longer exists, and settings that
// bill every month (dedicated-IP SSL, AWS WAF web ACL, Anycast static IP list).
// Report only by default. --apply --disable E1,E2 sets Enabled=false on the named distributions,
// and only if they had zero requests in the window. It never deletes anything.
// Usage: npx tsx find-unused-cloudfront-distributions.ts [--days 30] [--disable E123ABC [--apply]]
import {
  CloudFrontClient,
  GetDistributionConfigCommand,
  UpdateDistributionCommand,
  paginateListDistributions,
  type DistributionSummary,
} from "@aws-sdk/client-cloudfront";
import { CloudWatchClient, GetMetricDataCommand, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";
import { S3Client, HeadBucketCommand, paginateListBuckets, S3ServiceException } from "@aws-sdk/client-s3";

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 days = Number(flag("--days") ?? 30);
const toDisable = new Set((flag("--disable") ?? "").split(",").map((s) => s.trim()).filter(Boolean));
const apply = args.includes("--apply");

// CloudFront is global: its API and its CloudWatch metrics are both in us-east-1.
const cloudfront = new CloudFrontClient({ region: "us-east-1" });
const cloudwatch = new CloudWatchClient({ region: "us-east-1" });
const s3 = new S3Client({ region: "us-east-1", followRegionRedirects: true });

interface Row {
  Id: string;
  Domain: string;
  Enabled: boolean;
  Requests: number;
  GBOut: string;
  S3Origins: string;
  MonthlyExtras: string;
  Verdict: string;
}

const errorText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));

/** Bucket name from an S3 REST or website endpoint origin, or undefined for other origins. */
function bucketFromOrigin(domain: string): string | undefined {
  const m = /^(.+?)\.s3(?:[.-][a-z0-9-]+)?\.amazonaws\.com$/.exec(domain) // bucket.s3.amazonaws.com, bucket.s3.eu-west-1.amazonaws.com
    ?? /^(.+?)\.s3-website[.-][a-z0-9-]+\.amazonaws\.com$/.exec(domain); // website endpoints
  return m?.[1];
}

async function ownBuckets(): Promise<Set<string>> {
  const names = new Set<string>();
  for await (const page of paginateListBuckets({ client: s3 }, {})) {
    for (const b of page.Buckets ?? []) if (b.Name) names.add(b.Name);
  }
  return names;
}

/** "missing" when S3 answers 404 (the name is free for anyone to create), "other account" otherwise. */
async function bucketState(bucket: string): Promise<"missing" | "other account"> {
  try {
    await s3.send(new HeadBucketCommand({ Bucket: bucket }));
    return "other account";
  } catch (err) {
    return err instanceof S3ServiceException && err.$metadata.httpStatusCode === 404 ? "missing" : "other account";
  }
}

async function traffic(ids: string[]): Promise<Map<string, { requests: number; bytes: number }>> {
  const result = new Map<string, { requests: number; bytes: number }>();
  const end = new Date();
  const start = new Date(end.getTime() - days * 86_400_000);
  for (let i = 0; i < ids.length; i += 250) { // 2 queries per distribution, 500 per call
    const byQuery = new Map<string, { id: string; metric: "Requests" | "BytesDownloaded" }>();
    const queries: MetricDataQuery[] = [];
    ids.slice(i, i + 250).forEach((id, n) => {
      for (const metric of ["Requests", "BytesDownloaded"] as const) {
        const Id = `${metric === "Requests" ? "r" : "b"}${i + n}`;
        byQuery.set(Id, { id, metric });
        queries.push({
          Id,
          MetricStat: {
            Metric: {
              Namespace: "AWS/CloudFront",
              MetricName: metric,
              Dimensions: [{ Name: "DistributionId", Value: id }, { Name: "Region", Value: "Global" }],
            },
            Period: 86_400,
            Stat: "Sum",
          },
        });
      }
    });
    let NextToken: string | undefined;
    do {
      const out = await cloudwatch.send(new GetMetricDataCommand({ MetricDataQueries: queries, StartTime: start, EndTime: end, NextToken }));
      for (const r of out.MetricDataResults ?? []) {
        const q = byQuery.get(r.Id ?? "");
        if (!q) continue;
        const entry = result.get(q.id) ?? { requests: 0, bytes: 0 };
        const sum = (r.Values ?? []).reduce((s, v) => s + v, 0);
        if (q.metric === "Requests") entry.requests += sum;
        else entry.bytes += sum;
        result.set(q.id, entry);
      }
      NextToken = out.NextToken;
    } while (NextToken);
  }
  return result;
}

function extras(d: DistributionSummary): string[] {
  const out: string[] = [];
  if (d.ViewerCertificate?.SSLSupportMethod === "vip") out.push("dedicated-IP SSL");
  if (d.WebACLId) out.push("WAF web ACL");
  if (d.AnycastIpListId) out.push("Anycast IP list");
  return out;
}

async function main(): Promise<void> {
  const dists: DistributionSummary[] = [];
  for await (const page of paginateListDistributions({ client: cloudfront }, {})) {
    dists.push(...(page.DistributionList?.Items ?? []));
  }
  if (dists.length === 0) {
    console.log("No CloudFront distributions in this account.");
    return;
  }
  const usage = await traffic(dists.map((d) => d.Id ?? ""));
  const mine = await ownBuckets();
  const stateCache = new Map<string, string>();

  const rows: Row[] = [];
  for (const d of dists) {
    const id = d.Id ?? "";
    const u = usage.get(id) ?? { requests: 0, bytes: 0 };
    const s3Notes: string[] = [];
    for (const o of d.Origins?.Items ?? []) {
      const bucket = bucketFromOrigin(o.DomainName ?? "");
      if (!bucket || mine.has(bucket)) continue;
      const state = stateCache.get(bucket) ?? (await bucketState(bucket));
      stateCache.set(bucket, state);
      s3Notes.push(`${bucket} (${state})`);
    }
    const verdicts: string[] = [];
    if (s3Notes.some((n) => n.endsWith("(missing)"))) verdicts.push("dangling S3 origin");
    if (u.requests === 0) verdicts.push(d.Enabled ? "no requests: disable?" : "disabled, no requests: delete?");
    rows.push({
      Id: id,
      Domain: d.Aliases?.Items?.[0] ?? d.DomainName ?? "",
      Enabled: d.Enabled ?? false,
      Requests: Math.round(u.requests),
      GBOut: (u.bytes / 1e9).toFixed(2),
      S3Origins: s3Notes.join(", ") || "ok",
      MonthlyExtras: extras(d).join(", ") || "none",
      Verdict: verdicts.join("; ") || "in use",
    });
  }
  rows.sort((a, b) => a.Requests - b.Requests);
  console.table(rows);
  const unused = rows.filter((r) => r.Requests === 0);
  const dangling = rows.filter((r) => r.Verdict.includes("dangling"));
  console.log(`${unused.length} of ${rows.length} distributions had no viewer requests in ${days} days; ` +
    `${dangling.length} point at an S3 bucket that no longer exists.`);

  for (const id of toDisable) {
    const row = rows.find((r) => r.Id === id);
    if (!row || row.Requests > 0) {
      console.log(`skip ${id}: ${row ? `${row.Requests} requests in ${days} days` : "not found"}`);
      continue;
    }
    if (!row.Enabled) {
      console.log(`skip ${id}: already disabled`);
      continue;
    }
    if (!apply) {
      console.log(`would disable ${id} (${row.Domain}) (re-run with --apply)`);
      continue;
    }
    try {
      // UpdateDistribution replaces the whole config, so send back what GetDistributionConfig returned.
      const current = await cloudfront.send(new GetDistributionConfigCommand({ Id: id }));
      if (!current.DistributionConfig) throw new Error("no DistributionConfig returned");
      await cloudfront.send(new UpdateDistributionCommand({
        Id: id,
        IfMatch: current.ETag,
        DistributionConfig: { ...current.DistributionConfig, Enabled: false },
      }));
      console.log(`disabled ${id}; wait for Status Deployed before any delete`);
    } catch (err) {
      console.error(`disable ${id}: ${errorText(err)}`);
    }
  }
}

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

How do you run it?

Terminal

npm install @aws-sdk/client-cloudfront @aws-sdk/client-cloudwatch @aws-sdk/client-s3
npm install --save-dev tsx typescript @types/node

# Report over the default 30 days, or 90 for seasonal sites
AWS_PROFILE=readonly npx tsx find-unused-cloudfront-distributions.ts
AWS_PROFILE=readonly npx tsx find-unused-cloudfront-distributions.ts --days 90

# Preview, then disable one distribution
AWS_PROFILE=readonly npx tsx find-unused-cloudfront-distributions.ts --disable E2B3C4D5E6F7A8
AWS_PROFILE=ops-admin npx tsx find-unused-cloudfront-distributions.ts --disable E2B3C4D5E6F7A8 --apply

Sample output

Output

┌─────────┬──────────────────┬──────────────────────────┬─────────┬──────────┬──────────┬─────────────────────────────────┬────────────────────┬──────────────────────────────────────────────────────┐
│ (index) │ Id               │ Domain                   │ Enabled │ Requests │ GBOut    │ S3Origins                       │ MonthlyExtras      │ Verdict                                              │
├─────────┼──────────────────┼──────────────────────────┼─────────┼──────────┼──────────┼─────────────────────────────────┼────────────────────┼──────────────────────────────────────────────────────┤
│ 0       │ 'E2B3C4D5E6F7A8' │ 'promo-2025.example.com' │ true    │ 0        │ '0.00'   │ 'promo-2025-assets (missing)'   │ 'WAF web ACL'      │ 'dangling S3 origin; no requests: disable?'          │
│ 1       │ 'E3C4D5E6F7A8B9' │ 'legacy.example.com'     │ true    │ 0        │ '0.00'   │ 'ok'                            │ 'dedicated-IP SSL' │ 'no requests: disable?'                              │
│ 2       │ 'E4D5E6F7A8B9C0' │ 'd444.cloudfront.net'    │ false   │ 0        │ '0.00'   │ 'old-docs (missing)'            │ 'none'             │ 'dangling S3 origin; disabled, no requests: delete?' │
│ 3       │ 'E5E6F7A8B9C0D1' │ 'media.example.com'      │ true    │ 1200     │ '3.10'   │ 'partner-media (other account)' │ 'none'             │ 'in use'                                             │
│ 4       │ 'E1A2B3C4D5E6F7' │ 'www.example.com'        │ true    │ 48210000 │ '912.00' │ 'ok'                            │ 'none'             │ 'in use'                                             │
└─────────┴──────────────────┴──────────────────────────┴─────────┴──────────┴──────────┴─────────────────────────────────┴────────────────────┴──────────────────────────────────────────────────────┘
3 of 5 distributions had no viewer requests in 30 days; 2 point at an S3 bucket that no longer exists.
would disable E2B3C4D5E6F7A8 (promo-2025.example.com) (re-run with --apply)
skip E5E6F7A8B9C0D1: 1200 requests in 30 days

This run used mocked CloudFront, CloudWatch and S3 responses. promo-2025.example.com is the urgent row: no traffic, a web ACL still billing, and an origin bucket that no longer exists. legacy.example.com serves nothing but pays for dedicated-IP SSL. E4D5E6F7A8B9C0 is already disabled with a dangling website origin and is ready to delete. media.example.com uses a bucket in another account; confirm that’s the partner you expect.

How do you retire an unused distribution safely?

  1. Remove DNS firstDelete or repoint the records for its alternate domain names so no hostname points at a distribution you’re about to remove. If DNS and CloudFront are tangled, troubleshooting a Route 53 domain in front of CloudFront walks through how they connect.
  2. Disable and waitRun the script with --apply, then wait until the status is Deployed. A disabled distribution stops serving but can be re-enabled if someone complains.
  3. Delete with the new ETagDeleteDistribution needs the ETag from after you disabled it. The API reference lists DistributionNotDisabled for a distribution that’s still enabled, and InvalidIfMatchVersion or PreconditionFailed for a missing or out-of-date If-Match value. Deletion can’t be undone.
  4. Clean up what it usedDelete the web ACL if nothing else uses it, the certificate (see find unused ACM certificates), and log buckets you no longer need.

Keep distributions you still run in good shape with the checks to find CloudFront distributions without AWS WAF, find CloudFront distributions without access logging and check the CloudFront minimum TLS version.

Troubleshooting

  • Every distribution shows 0 requests. CloudFront metrics are only in us-east-1 and need Region=Global. The script sets both; if you adapt it, keep them.
  • An S3 origin you own shows “other account”. The origin may use an access point alias or a dual-stack endpoint that the parser doesn’t recognize as your bucket name. Check the origin domain by hand.
  • PreconditionFailed on --apply. The If-Match value no longer matched, usually because someone changed the distribution between the read and the update. Run it again; the script reads a fresh ETag each time.
  • A disabled distribution still shows plan charges. Flat-rate plans bill until cancelled. Cancel the plan in the console, then delete after the billing cycle ends.
  • Access denied. Compare with the policy above and follow troubleshooting AWS IAM access denied errors.

Ask ChatWithCloud instead

For a quick answer, ask ChatWithCloud “Which CloudFront distributions had no requests in the last 30 days?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the result; how ChatWithCloud turns questions into AWS SDK calls covers the details. It can be wrong and runs changes without confirmation, so use a read-only profile and keep disabling in the script. More cleanup scripts are in the AWS practical examples hub.

Frequently asked questions

Do unused CloudFront distributions cost money?

On pay-as-you-go pricing, a distribution with no traffic has almost no usage charges. Attached features still bill monthly: dedicated-IP SSL, AWS WAF web ACLs and rules, and flat-rate plan fees.

Can I delete a CloudFront distribution without disabling it?

No. You must disable it first, which needs permission to update it, and pass the latest ETag as IfMatch when you delete.

Where are CloudFront metrics in CloudWatch?

In us-east-1, in the AWS/CloudFront namespace, with the DistributionId dimension and Region set to Global. Requests and BytesDownloaded use the Sum statistic.

What happens if my CloudFront origin bucket was deleted?

The bucket name can become available to other AWS accounts. Point the origin at a bucket you own, or disable the distribution, as soon as you find one.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud