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?
- Lists distributions
paginateListDistributionson the CloudFront API, which is global. - Reads trafficOne
GetMetricDatacall per 250 distributions for dailySumofRequestsandBytesDownloadedover--days(default 30), in us-east-1 withDistributionIdandRegion=Global. - Checks S3 originsParses REST and website endpoint origins, compares them with
ListBuckets, and callsHeadBucketfor the rest. - Flags monthly add-onsDedicated-IP SSL, an attached web ACL and an Anycast static IP list.
- Disables only on requestReads the config and
ETagwithGetDistributionConfig, then sendsUpdateDistributionwithEnabled: falseandIfMatch.
Prerequisites
- Node.js 18 or later,
tsx,@aws-sdk/client-cloudfront,@aws-sdk/client-cloudwatchand@aws-sdk/client-s3. - A read-only profile for the report; connecting ChatWithCloud and scripts to AWS profiles and roles covers the setup.
- An idea of which hostnames matter. The Route 53 records that point at each distribution are worth listing first; the script to find unused Route 53 hosted zones shows the zones involved.
Which IAM permissions does it need?
{
"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
// 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?
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
┌─────────┬──────────────────┬──────────────────────────┬─────────┬──────────┬──────────┬─────────────────────────────────┬────────────────────┬──────────────────────────────────────────────────────┐
│ (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?
- 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.
- Disable and waitRun the script with
--apply, then wait until the status isDeployed. A disabled distribution stops serving but can be re-enabled if someone complains. - Delete with the new ETag
DeleteDistributionneeds theETagfrom after you disabled it. The API reference listsDistributionNotDisabledfor a distribution that’s still enabled, andInvalidIfMatchVersionorPreconditionFailedfor a missing or out-of-dateIf-Matchvalue. Deletion can’t be undone. - 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.
PreconditionFailedon--apply. TheIf-Matchvalue no longer matched, usually because someone changed the distribution between the read and the update. Run it again; the script reads a freshETageach 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
