Photo by Kirill Sh on Unsplash
To find unused VPC endpoints, list interface and Gateway Load Balancer endpoints with EC2 DescribeVpcEndpoints, then sum the CloudWatch metric BytesProcessed in the AWS/PrivateLinkEndpoints namespace for each one over 14 days. No data points means no traffic. Each interface endpoint is billed per hour in every Availability Zone it’s in; gateway endpoints for S3 and DynamoDB are free.
Interface endpoints tend to arrive in sets. A platform module adds ssm, ssmmessages, ecr.api, ecr.dkr, logs and sts to every VPC, in three Availability Zones, and then half of those VPCs never run anything that calls those services. This example is for engineers who want to find unused VPC endpoints with traffic data, see what each costs, and decide which to remove.
You’ll get a read-only TypeScript script for the AWS SDK for JavaScript v3. It’s part of our AWS SDK v3 cost cleanup examples, alongside the script to find idle NAT gateways costing you money, which covers the other big hourly network charge.
What does an unused interface endpoint cost?
As of September 2026, the AWS PrivateLink pricing page and the AWS Price List give these us-east-1 rates. Other Regions differ, and each partial endpoint-hour is billed as a full hour.
| Endpoint type | Hourly charge | Data processed |
|---|---|---|
| Interface endpoint | $0.01 per endpoint per Availability Zone | $0.01 per GB for the first 1 PB a month, then $0.006, then $0.004 above 5 PB |
| Gateway Load Balancer endpoint | $0.01 per endpoint | $0.0035 per GB |
| Gateway endpoint (S3, DynamoDB) | No charge | No charge |
The hourly part is what an unused endpoint costs, because it’s charged whether traffic flows or not. Worked example with 730 hours in a month: one endpoint in 3 Availability Zones is 3 × $0.01 × 730 = $21.90 a month. A standard set of 6 such endpoints in a dev VPC that never uses them is 6 × $21.90 = $131.40 a month, or $1,576.80 a year. Multiply by the number of VPCs that got the same module and the total gets noticed.
The script to find your most expensive AWS service with Cost Explorer shows how large VPC charges are for you before you start.
How do you tell that a VPC endpoint is unused?
AWS PrivateLink publishes metrics for every interface and Gateway Load Balancer endpoint at one-minute intervals, at no extra charge. The script relies on two of them:
BytesProcessedis the bytes exchanged in both directions, the same number the data processing charge is based on.NewConnectionsis the number of connections opened through the endpoint, useful for spotting an endpoint that’s only touched by a health check.
Both are reported only for minutes in which the endpoint received traffic, so an endpoint with no data points over 14 days had no traffic. The metrics use the dimensions Endpoint Type, Service Name, VPC Endpoint Id and VPC Id, with spaces in the names, and all four have to match. Gateway endpoints publish no metrics at all, which is fine because they don’t cost anything.
What does the script do?
- Lists Regions
DescribeRegions, or--regions=. - Lists endpoints
paginateDescribeVpcEndpoints, keepingInterfaceandGatewayLoadBalancertypes and counting gateway endpoints. Resource and service network endpoints are skipped. - Sums traffic
GetMetricDatawith dailySumofBytesProcessedandNewConnections, two queries per endpoint and up to 500 queries per call. - Estimates the hourly costNumber of subnets (one per Availability Zone) × $0.01 × 730.
- Prints a verdictIt never deletes an endpoint.
Prerequisites
- Node.js 18 or later, npm and
tsx. - The
@aws-sdk/client-ec2and@aws-sdk/client-cloudwatchpackages. - An AWS profile; the guide to AWS SDK v3 credential providers such as fromIni covers SSO and assumed roles.
Which IAM permissions does it need?
Three read actions. The script has no write path, so there’s no second statement.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadEndpointsAndTraffic",
"Effect": "Allow",
"Action": [
"ec2:DescribeRegions",
"ec2:DescribeVpcEndpoints",
"cloudwatch:GetMetricData"
],
"Resource": "*"
}
]
}
The free IAM policy generator for TypeScript AWS SDK code builds the same list from the script.
The full script to find unused VPC endpoints
// find-unused-vpc-endpoints.ts
// Lists interface and Gateway Load Balancer VPC endpoints in every enabled Region, sums the CloudWatch
// BytesProcessed and NewConnections metrics for each over the last 14 days, and estimates what each
// endpoint costs per month in hourly charges. Gateway endpoints (S3, DynamoDB) are free and only counted.
// Read-only: it never deletes an endpoint.
// Usage: npx tsx find-unused-vpc-endpoints.ts [--regions=us-east-1,eu-west-1] [--days=14]
import { EC2Client, DescribeRegionsCommand, paginateDescribeVpcEndpoints, type VpcEndpoint } from "@aws-sdk/client-ec2";
import { CloudWatchClient, GetMetricDataCommand, type MetricDataQuery } from "@aws-sdk/client-cloudwatch";
const args = process.argv.slice(2);
const regionArg = args.find((a) => a.startsWith("--regions="))?.split("=")[1];
const LOOKBACK_DAYS = Number(args.find((a) => a.startsWith("--days="))?.split("=")[1] ?? 14);
// us-east-1 rates from the AWS Price List (September 2026). Other Regions differ.
const HOURLY_PER_AZ = 0.01;
const HOURS_PER_MONTH = 730;
interface Row { Region: string; Endpoint: string; Type: string; Service: string; AZs: number; GB: string; NewConns: number; PerMonth: string; Verdict: string }
async function listRegions(): Promise<string[]> {
if (regionArg) return regionArg.split(",").map((r) => r.trim()).filter(Boolean);
const out = await new EC2Client({}).send(new DescribeRegionsCommand({}));
return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}
// Sums BytesProcessed and NewConnections per endpoint. Two queries per endpoint, 500 queries per call.
async function traffic(cw: CloudWatchClient, eps: VpcEndpoint[]): Promise<Map<string, { bytes: number; conns: number }>> {
const totals = new Map<string, { bytes: number; conns: number }>();
const end = new Date();
const start = new Date(end.getTime() - LOOKBACK_DAYS * 86_400_000);
for (let i = 0; i < eps.length; i += 250) {
const batch = eps.slice(i, i + 250);
const queries: MetricDataQuery[] = batch.flatMap((ep, n) => {
const dims = [
{ Name: "Endpoint Type", Value: ep.VpcEndpointType ?? "" },
{ Name: "Service Name", Value: ep.ServiceName ?? "" },
{ Name: "VPC Endpoint Id", Value: ep.VpcEndpointId ?? "" },
{ Name: "VPC Id", Value: ep.VpcId ?? "" },
];
return (["BytesProcessed", "NewConnections"] as const).map((metric) => ({
Id: `${metric === "BytesProcessed" ? "b" : "c"}${n}`,
MetricStat: { Metric: { Namespace: "AWS/PrivateLinkEndpoints", MetricName: metric, Dimensions: dims }, Period: 86_400, Stat: "Sum" },
}));
});
let NextToken: string | undefined;
do {
const res = await cw.send(new GetMetricDataCommand({ MetricDataQueries: queries, StartTime: start, EndTime: end, NextToken }));
for (const r of res.MetricDataResults ?? []) {
const id = r.Id ?? "b0";
const ep = batch[Number(id.slice(1))];
const key = ep.VpcEndpointId ?? "";
const sum = (r.Values ?? []).reduce((a, b) => a + b, 0);
const t = totals.get(key) ?? { bytes: 0, conns: 0 };
if (id.startsWith("b")) t.bytes += sum;
else t.conns += sum;
totals.set(key, t);
}
NextToken = res.NextToken;
} while (NextToken);
}
return totals;
}
async function scanRegion(region: string): Promise<{ rows: Row[]; gateway: number }> {
const ec2 = new EC2Client({ region });
const paid: VpcEndpoint[] = [];
let gateway = 0;
for await (const page of paginateDescribeVpcEndpoints({ client: ec2 }, {})) {
for (const ep of page.VpcEndpoints ?? []) {
if (String(ep.State ?? "").toLowerCase().startsWith("delet")) continue; // deleting or deleted
if (ep.VpcEndpointType === "Gateway") gateway++;
else if (ep.VpcEndpointType === "Interface" || ep.VpcEndpointType === "GatewayLoadBalancer") paid.push(ep);
}
}
const totals = paid.length ? await traffic(new CloudWatchClient({ region }), paid) : new Map<string, { bytes: number; conns: number }>();
const rows = paid.map((ep): Row => {
const t = totals.get(ep.VpcEndpointId ?? "") ?? { bytes: 0, conns: 0 };
const azs = ep.SubnetIds?.length ?? 0;
const gb = t.bytes / 1024 ** 3;
const verdict = ep.RequesterManaged ? "managed by a service: leave it"
: t.bytes === 0 ? `UNUSED: no traffic in ${LOOKBACK_DAYS} days`
: gb < 0.01 ? "barely used: check before keeping"
: "in use";
return {
Region: region,
Endpoint: ep.VpcEndpointId ?? "",
Type: ep.VpcEndpointType ?? "",
Service: (ep.ServiceName ?? "").replace(`com.amazonaws.${region}.`, ""),
AZs: azs,
GB: gb.toFixed(3),
NewConns: t.conns,
PerMonth: `$${(azs * HOURLY_PER_AZ * HOURS_PER_MONTH).toFixed(2)}`,
Verdict: verdict,
};
});
return { rows, gateway };
}
async function main(): Promise<void> {
const rows: Row[] = [];
let gateways = 0;
for (const region of await listRegions()) {
try {
const r = await scanRegion(region);
rows.push(...r.rows);
gateways += r.gateway;
} catch (err) {
console.error(`${region}: skipped (${err instanceof Error ? `${err.name}: ${err.message}` : String(err)})`);
}
}
console.table(rows);
const unused = rows.filter((r) => r.Verdict.startsWith("UNUSED"));
const waste = unused.reduce((sum, r) => sum + r.AZs * HOURLY_PER_AZ * HOURS_PER_MONTH, 0);
console.log(`${rows.length} interface/GWLB endpoints; ${unused.length} had no traffic (~$${waste.toFixed(2)}/month at us-east-1 rates). ${gateways} gateway endpoints (no charge) skipped.`);
console.log("Read-only: nothing was deleted.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-ec2 @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript
# 14 days, every enabled Region
AWS_PROFILE=readonly npx tsx find-unused-vpc-endpoints.ts
# A longer window catches monthly batch jobs
AWS_PROFILE=readonly npx tsx find-unused-vpc-endpoints.ts --regions=us-east-1 --days=45
Sample output
┌─────────┬─────────────┬──────────────────────────┬───────────────────────┬───────────────────────────────────────────────────────────┬─────┬───────────┬──────────┬──────────┬─────────────────────────────────────┐
│ (index) │ Region │ Endpoint │ Type │ Service │ AZs │ GB │ NewConns │ PerMonth │ Verdict │
├─────────┼─────────────┼──────────────────────────┼───────────────────────┼───────────────────────────────────────────────────────────┼─────┼───────────┼──────────┼──────────┼─────────────────────────────────────┤
│ 0 │ 'eu-west-1' │ 'vpce-0a1b2c3d4e5f60718' │ 'Interface' │ 'ecr.dkr' │ 3 │ '412.883' │ 918233 │ '$21.90' │ 'in use' │
│ 1 │ 'eu-west-1' │ 'vpce-0b2c3d4e5f6071829' │ 'Interface' │ 'ssm' │ 3 │ '0.000' │ 0 │ '$21.90' │ 'UNUSED: no traffic in 14 days' │
│ 2 │ 'us-east-1' │ 'vpce-0c3d4e5f607182930' │ 'Interface' │ 'secretsmanager' │ 2 │ '0.004' │ 38 │ '$14.60' │ 'barely used: check before keeping' │
│ 3 │ 'us-east-1' │ 'vpce-0d4e5f60718293a41' │ 'Interface' │ 'kinesis-streams' │ 3 │ '0.000' │ 0 │ '$21.90' │ 'UNUSED: no traffic in 14 days' │
│ 4 │ 'us-east-1' │ 'vpce-0e5f60718293a4b52' │ 'GatewayLoadBalancer' │ 'com.amazonaws.vpce.us-east-1.vpce-svc-0123456789abcdef0' │ 1 │ '96.120' │ 40211 │ '$7.30' │ 'in use' │
└─────────┴─────────────┴──────────────────────────┴───────────────────────┴───────────────────────────────────────────────────────────┴─────┴───────────┴──────────┴──────────┴─────────────────────────────────────┘
5 interface/GWLB endpoints; 2 had no traffic (~$43.80/month at us-east-1 rates). 4 gateway endpoints (no charge) skipped.
Read-only: nothing was deleted.
IDs are illustrative. The ssm and kinesis-streams endpoints had no traffic at all. The secretsmanager endpoint handled about 4 MB: something uses it, just rarely, so find out what before you remove it.
What should you check before deleting a VPC endpoint?
- Private DNS. With private DNS enabled, the service’s default hostname resolves to the endpoint inside the VPC. Delete the endpoint and the same hostname resolves to the public service endpoint, so instances in private subnets either go out through a NAT gateway, adding its data processing charge, or can’t reach the service at all.
- Rare but critical callers. Disaster-recovery runbooks, quarterly jobs and Session Manager access during an incident may use an endpoint once in a long while. Run with a longer
--dayswindow before deciding. - Endpoint policies and security groups. Deleting an endpoint leaves its security groups behind; the script to find unused security groups in your account picks them up afterwards. Its network interfaces, one per subnet, are requester-managed and go with it, unlike the leftovers the script to find unattached elastic network interfaces reports.
- Fewer Availability Zones. An endpoint that’s used but lightly may not need three zones. Removing a subnet from it with
ModifyVpcEndpointcuts the hourly charge by a third, at the cost of zone redundancy.
If an S3 or DynamoDB interface endpoint is used only from inside the same VPC, compare it with a gateway endpoint for the same service, which has no hourly or data processing charge. After cleaning up, the script to get last month’s AWS cost broken down by service confirms the saving.
Troubleshooting
- Every endpoint shows 0 GB, including busy ones. Check the dimensions. All four must match exactly, including the spaces in
VPC Endpoint Id, and the script must query the Region the endpoint is in. - An endpoint used from another Region shows no traffic. PrivateLink doesn’t publish endpoint metrics for consumers that use cross-Region access. Check with the endpoint service owner.
- A Region is skipped with
UnauthorizedOperation. The profile or an SCP blocks the call. The guide to troubleshoot IAM access denied errors in AWS shows how to trace it.
Ask ChatWithCloud instead
You can also ask ChatWithCloud “Which interface VPC endpoints in eu-west-1 had no traffic in the last two weeks?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and explains the result, much like the questions in the guide to ask AI why your AWS bill increased. It works in one profile and Region per session and runs generated code without a confirmation step, so connect ChatWithCloud to your AWS account with a read-only profile. The ChatWithCloud security model explains what stays on your machine.
Frequently asked questions
Do VPC endpoints cost money when unused?
Interface and Gateway Load Balancer endpoints do: they’re billed for every hour they’re provisioned, per Availability Zone for interface endpoints. Gateway endpoints for S3 and DynamoDB have no charge.
How do I see traffic through a VPC endpoint?
In CloudWatch, under the AWS/PrivateLinkEndpoints namespace, or on the endpoint’s Monitoring tab in the VPC console. BytesProcessed and NewConnections are the most useful metrics.
Why doesn’t my gateway endpoint have CloudWatch metrics?
PrivateLink metrics are published only for interface endpoints, Gateway Load Balancer endpoints and endpoint services, not for gateway endpoints.
Does the script delete endpoints?
No. It only reads endpoints and metrics. Delete endpoints yourself after checking the points above.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud