
Photo by Brett Sayles on Pexels
An unused RDS Proxy costs $0.015 per vCPU-hour of the database instances behind it (us-east-1, September 2026) whether or not any client connects. Find unused proxies with DescribeDBProxies and DescribeDBProxyTargets, then sum the ClientConnectionsReceived and QueryRequests metrics per ProxyName in CloudWatch over 14 days. Zero means nobody uses it.
RDS Proxy pools database connections for Lambda functions and other bursty clients. Teams add one during a migration or a load test, move the application back to the database endpoint, and the proxy keeps billing. Because the price follows the size of the database, not the traffic, the RDS Proxy cost of an unused proxy in front of a large instance adds up.
This example is for engineers cleaning up a database estate. The script lists every proxy in the Regions you choose, its targets and their health, 14 days of client activity and a monthly estimate. It only deletes when you pass --apply and name the proxies. For the databases themselves, pair it with the script to find idle RDS instances by DatabaseConnections.
How is RDS Proxy cost calculated?
As of September 2026, the AWS Price List for Amazon RDS (published 24 September 2026) shows these us-east-1 rates for the RDS:ProxyUsage and RDS:Proxy-ASv2-Usage usage types:
| Database behind the proxy | Price |
|---|---|
| RDS for MySQL, PostgreSQL, MariaDB or SQL Server; provisioned Aurora | $0.015 per vCPU-hour of the associated DB instances |
| Aurora Serverless v2 | $0.015 per ACU-hour of the underlying instances |
| Default proxy endpoint | No extra charge |
| Additional read-only or read/write endpoints | AWS PrivateLink interface endpoint charges |
AWS’s RDS Proxy pricing page adds that billing is per second, with a 10-minute minimum after a billable status change such as creating, starting or modifying.
Worked example: a proxy in front of a db.r6g.2xlarge (8 vCPUs) costs 8 × $0.015 × 730 hours = $87.60 a month. A proxy on an Aurora cluster with two db.r6g.large instances (2 vCPUs each) costs 4 × $0.015 × 730 = $43.80. Over a year, the first one alone is $1,051.20 for a proxy nobody connects to.
How do you know an RDS Proxy is unused?
RDS Proxy publishes per-proxy metrics in the AWS/RDS namespace with the ProxyName dimension. The script reads three of them with one daily data point each:
ClientConnectionsReceived(Sum): client connection requests. Zero over the window means no application opened a connection.ClientConnections(Maximum): current client connections. It catches a long-lived pool that connected before the window started.QueryRequests(Sum): queries received. The RDS docs note it doesn’t count PostgreSQL workloads that use the extended protocol, so it’s a hint, not proof.
The docs also warn that some metrics don’t appear until a proxy’s first successful connection. The script treats missing data as zero, which is what you want here. It labels each proxy:
- UNUSED: no targets registered: nothing behind it, so it can’t serve traffic.
- UNUSED: no client connections: targets exist, nobody connects.
- check: connections but no queries: connections exist but no counted queries; look closer before deleting.
- too new to judge for proxies younger than the window, and check: status for anything not
available.
Target health adds context. A target in UNAVAILABLE with reason AUTH_FAILURE or CONNECTION_FAILED usually means the secret or network path broke long ago and nobody noticed.
What does the script do?
- Lists proxies
DescribeDBProxies, followingMarkeruntil every proxy is read. - Reads targets
paginateDescribeDBProxyTargetsfor each proxy, with target type, health state and reason. - Counts vCPUs
DescribeDBInstancesgives each target’s class;DescribeInstanceTypeson the matching EC2 type (db.r6g.largebecomesr6g.large) gives its default vCPUs. - Reads activityOne
GetMetricDatarequest per proxy for the three metrics above. - Reports and deletes on requestA table and monthly total;
--apply --proxy namecallsDeleteDBProxyfor named proxies flagged UNUSED.
Prerequisites
- Node.js 18 or later with
tsx, plus@aws-sdk/client-rds,@aws-sdk/client-ec2and@aws-sdk/client-cloudwatch. - A read-only AWS profile, and a separate role for deletion.
- A sense of the bill. Finding your most expensive AWS service with Cost Explorer shows whether RDS is where the money goes.
Which IAM permissions does it need?
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReportOnProxies",
"Effect": "Allow",
"Action": [
"rds:DescribeDBProxies",
"rds:DescribeDBProxyTargets",
"rds:DescribeDBInstances",
"ec2:DescribeInstanceTypes",
"cloudwatch:GetMetricData"
],
"Resource": "*"
},
{
"Sid": "DeleteOnlyWithApply",
"Effect": "Allow",
"Action": "rds:DeleteDBProxy",
"Resource": "arn:aws:rds:*:123456789012:db-proxy:*"
}
]
}
Replace the account ID, and drop the second statement for a report-only role. Proxy ARNs use the proxy’s resource ID (db-proxy:prx-...), not its name. To list the actions for your own changes, paste the code into the free IAM policy generator for TypeScript.
The script to find unused RDS Proxies
// find-unused-rds-proxies.ts
// Lists RDS Proxies with their targets, target health, client activity over the last N days and an
// estimated monthly cost, and flags the ones nobody connects to.
// Report only by default. --apply --proxy name1,name2 deletes the named proxies if they are flagged unused.
// Usage: npx tsx find-unused-rds-proxies.ts [--regions us-east-1,eu-west-1] [--days 14] [--csv proxies.csv]
// [--apply --proxy orders-proxy]
import { writeFileSync } from "node:fs";
import {
RDSClient,
DeleteDBProxyCommand,
DescribeDBInstancesCommand,
DescribeDBProxiesCommand,
paginateDescribeDBProxyTargets,
type DBProxy,
type DBProxyTarget,
} from "@aws-sdk/client-rds";
import { EC2Client, DescribeInstanceTypesCommand, type _InstanceType } from "@aws-sdk/client-ec2";
import { CloudWatchClient, paginateGetMetricData } from "@aws-sdk/client-cloudwatch";
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 days = Number(flag("--days") ?? 14);
const csvPath = flag("--csv");
const apply = args.includes("--apply");
const toDelete = new Set((flag("--proxy") ?? "").split(",").map((p) => p.trim()).filter(Boolean));
// us-east-1 USD from the AWS Price List (AmazonRDS), published 24 September 2026:
// RDS Proxy costs $0.015 per vCPU-hour of the provisioned instances behind it (per ACU-hour for Aurora Serverless v2).
const PER_VCPU_HOUR = 0.015;
const HOURS_PER_MONTH = 730;
interface Row {
Region: string;
Proxy: string;
Engine: string;
Status: string;
Targets: string;
TargetHealth: string;
vCPUs: number | string;
ConnReceived: number;
MaxClientConns: number;
Queries: number;
PerMonth: string;
Verdict: string;
}
async function allProxies(rds: RDSClient): Promise<DBProxy[]> {
const proxies: DBProxy[] = [];
let Marker: string | undefined;
do {
const page = await rds.send(new DescribeDBProxiesCommand({ Marker }));
proxies.push(...(page.DBProxies ?? []));
Marker = page.Marker;
} while (Marker);
return proxies;
}
async function targetsOf(rds: RDSClient, proxyName: string): Promise<DBProxyTarget[]> {
const targets: DBProxyTarget[] = [];
for await (const page of paginateDescribeDBProxyTargets({ client: rds }, { DBProxyName: proxyName })) {
targets.push(...(page.Targets ?? []));
}
return targets;
}
/** DB instance classes behind the proxy: instance targets, or the members of a tracked Aurora cluster. */
async function instanceClasses(rds: RDSClient, targets: DBProxyTarget[]): Promise<string[]> {
const ids = targets.filter((t) => t.Type === "RDS_INSTANCE" && t.RdsResourceId).map((t) => t.RdsResourceId as string);
const clusters = targets.filter((t) => t.Type === "TRACKED_CLUSTER" && t.TrackedClusterId).map((t) => t.TrackedClusterId as string);
const classes: string[] = [];
if (ids.length) {
for (const id of ids) {
const out = await rds.send(new DescribeDBInstancesCommand({ DBInstanceIdentifier: id }));
classes.push(...(out.DBInstances ?? []).map((db) => db.DBInstanceClass ?? ""));
}
} else if (clusters.length) {
const out = await rds.send(new DescribeDBInstancesCommand({ Filters: [{ Name: "db-cluster-id", Values: clusters }] }));
classes.push(...(out.DBInstances ?? []).map((db) => db.DBInstanceClass ?? ""));
}
return classes.filter(Boolean);
}
/** vCPUs per DB instance class, looked up via the matching EC2 instance type (db.r6g.large -> r6g.large). */
async function vcpusFor(ec2: EC2Client, classes: string[], cache: Map<string, number | undefined>): Promise<number | string> {
if (classes.length === 0) return "-";
let total = 0;
for (const cls of classes) {
if (cls === "db.serverless") return "ACU-billed";
if (!cache.has(cls)) {
try {
const out = await ec2.send(new DescribeInstanceTypesCommand({ InstanceTypes: [cls.replace(/^db\./, "") as _InstanceType] }));
cache.set(cls, out.InstanceTypes?.[0]?.VCpuInfo?.DefaultVCpus);
} catch {
cache.set(cls, undefined);
}
}
const v = cache.get(cls);
if (v === undefined) return `unknown (${cls})`;
total += v;
}
return total;
}
/** Client connection requests, peak client connections and queries per proxy over the window. */
async function activity(cw: CloudWatchClient, proxyName: string): Promise<{ received: number; peak: number; queries: number }> {
const end = new Date();
const start = new Date(end.getTime() - days * 86_400_000);
const q = (id: string, metric: string, stat: string) => ({
Id: id,
MetricStat: {
Metric: { Namespace: "AWS/RDS", MetricName: metric, Dimensions: [{ Name: "ProxyName", Value: proxyName }] },
Period: 86_400,
Stat: stat,
},
});
const result = { received: 0, peak: 0, queries: 0 };
for await (const page of paginateGetMetricData({ client: cw }, {
StartTime: start,
EndTime: end,
MetricDataQueries: [
q("received", "ClientConnectionsReceived", "Sum"),
q("peak", "ClientConnections", "Maximum"),
q("queries", "QueryRequests", "Sum"),
],
})) {
for (const r of page.MetricDataResults ?? []) {
const values = r.Values ?? [];
if (r.Id === "received") result.received += values.reduce((a, b) => a + b, 0);
if (r.Id === "queries") result.queries += values.reduce((a, b) => a + b, 0);
if (r.Id === "peak") result.peak = Math.max(result.peak, ...values, 0);
}
}
return result;
}
async function scanRegion(region: string): Promise<Row[]> {
const rds = new RDSClient({ region });
const ec2 = new EC2Client({ region });
const cw = new CloudWatchClient({ region });
const cache = new Map<string, number | undefined>();
const rows: Row[] = [];
for (const proxy of await allProxies(rds)) {
const name = proxy.DBProxyName ?? "";
const targets = await targetsOf(rds, name);
const dbTargets = targets.filter((t) => t.Type !== "TRACKED_CLUSTER");
const vcpus = await vcpusFor(ec2, await instanceClasses(rds, targets), cache);
const act = await activity(cw, name);
const ageDays = proxy.CreatedDate ? (Date.now() - proxy.CreatedDate.getTime()) / 86_400_000 : days;
let verdict = "in use";
if (proxy.Status !== "available") verdict = `check: status ${proxy.Status ?? "unknown"}`;
else if (targets.length === 0) verdict = "UNUSED: no targets registered";
else if (ageDays < days) verdict = "too new to judge";
else if (act.received === 0 && act.peak === 0) verdict = "UNUSED: no client connections";
else if (act.queries === 0) verdict = "check: connections but no queries";
rows.push({
Region: region,
Proxy: name,
Engine: proxy.EngineFamily ?? "",
Status: proxy.Status ?? "",
Targets: dbTargets.map((t) => t.RdsResourceId).join(", ") || targets.map((t) => t.TrackedClusterId).join(", ") || "-",
TargetHealth: [...new Set(dbTargets.map((t) => [t.TargetHealth?.State, t.TargetHealth?.Reason].filter(Boolean).join("/")))].join(", ") || "-",
vCPUs: vcpus,
ConnReceived: Math.round(act.received),
MaxClientConns: Math.round(act.peak),
Queries: Math.round(act.queries),
PerMonth: typeof vcpus === "number" ? `$${(vcpus * PER_VCPU_HOUR * HOURS_PER_MONTH).toFixed(2)}` : "see Cost Explorer",
Verdict: verdict,
});
}
return rows;
}
function toCsv(rows: Row[]): string {
const cols = Object.keys(rows[0] ?? {}) as (keyof Row)[];
const cell = (v: string | number) => `"${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> {
if (!Number.isInteger(days) || days < 1 || days > 455) throw new Error("--days must be a whole number from 1 to 455");
if (apply && toDelete.size === 0) throw new Error("--apply needs --proxy with the proxy names to delete");
const rows: Row[] = [];
for (const region of regions) {
try {
rows.push(...(await scanRegion(region)));
} catch (err) {
console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
}
}
if (rows.length === 0) {
console.log(`No RDS Proxies in ${regions.join(", ")}.`);
return;
}
console.table(rows);
const unused = rows.filter((r) => r.Verdict.startsWith("UNUSED"));
const monthly = unused.reduce((s, r) => s + (Number(r.PerMonth.replace("$", "")) || 0), 0);
console.log(`${unused.length} of ${rows.length} proxies unused for ${days} days: about $${monthly.toFixed(2)} a month (us-east-1 prices).`);
if (csvPath) {
writeFileSync(csvPath, toCsv(rows));
console.log(`Wrote ${rows.length} rows to ${csvPath}`);
}
if (!apply) return;
for (const name of toDelete) {
const row = unused.find((r) => r.Proxy === name);
if (!row) {
console.log(`${name}: not flagged UNUSED, skipped`);
continue;
}
const out = await new RDSClient({ region: row.Region }).send(new DeleteDBProxyCommand({ DBProxyName: name }));
const p = out.DBProxy;
console.log(`${name}: ${p?.Status ?? "deleting"}. Review leftovers: role ${p?.RoleArn ?? "-"}, ` +
`secrets ${(p?.Auth ?? []).map((a) => a.SecretArn).filter(Boolean).join(", ") || "-"}, ` +
`security groups ${(p?.VpcSecurityGroupIds ?? []).join(", ") || "-"}`);
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
How do you run it?
npm install @aws-sdk/client-rds @aws-sdk/client-ec2 @aws-sdk/client-cloudwatch
npm install --save-dev tsx typescript @types/node
# Report on two Regions over 30 days
AWS_PROFILE=readonly npx tsx find-unused-rds-proxies.ts --regions us-east-1,eu-west-1 --days 30 --csv proxies.csv
# Delete one proxy that the report flagged UNUSED
AWS_PROFILE=db-admin npx tsx find-unused-rds-proxies.ts --apply --proxy legacy-reports-proxy
Sample output
┌─────────┬─────────────┬────────────────────────┬──────────────┬─────────────┬────────────────────────┬────────────────────────────┬───────┬──────────────┬────────────────┬─────────┬─────────────────────┬─────────────────────────────────┐
│ (index) │ Region │ Proxy │ Engine │ Status │ Targets │ TargetHealth │ vCPUs │ ConnReceived │ MaxClientConns │ Queries │ PerMonth │ Verdict │
├─────────┼─────────────┼────────────────────────┼──────────────┼─────────────┼────────────────────────┼────────────────────────────┼───────┼──────────────┼────────────────┼─────────┼─────────────────────┼─────────────────────────────────┤
│ 0 │ 'us-east-1' │ 'orders-proxy' │ 'POSTGRESQL' │ 'available' │ 'orders-db' │ 'AVAILABLE' │ 4 │ 9120 │ 41 │ 1830000 │ '$43.80' │ 'in use' │
│ 1 │ 'us-east-1' │ 'legacy-reports-proxy' │ 'MYSQL' │ 'available' │ 'reports-db' │ 'AVAILABLE' │ 8 │ 0 │ 0 │ 0 │ '$87.60' │ 'UNUSED: no client connections' │
│ 2 │ 'us-east-1' │ 'billing-aurora-proxy' │ 'MYSQL' │ 'available' │ 'billing-1, billing-2' │ 'UNAVAILABLE/AUTH_FAILURE' │ 4 │ 0 │ 0 │ 0 │ '$43.80' │ 'UNUSED: no client connections' │
│ 3 │ 'us-east-1' │ 'poc-proxy' │ 'POSTGRESQL' │ 'available' │ '-' │ '-' │ '-' │ 0 │ 0 │ 0 │ 'see Cost Explorer' │ 'UNUSED: no targets registered' │
└─────────┴─────────────┴────────────────────────┴──────────────┴─────────────┴────────────────────────┴────────────────────────────┴───────┴──────────────┴────────────────┴─────────┴─────────────────────┴─────────────────────────────────┘
3 of 4 proxies unused for 14 days: about $131.40 a month (us-east-1 prices).
This run used mocked AWS responses, so names and numbers are illustrative. legacy-reports-proxy sits in front of an 8-vCPU instance and saw no connections in 14 days. billing-aurora-proxy has two targets failing with AUTH_FAILURE, a sign its secret was rotated or removed without anyone noticing. poc-proxy has no targets, so the script can’t price it; check the RDS:ProxyUsage line in Cost Explorer.
What should you clean up after deleting a proxy?
DeleteDBProxy removes the proxy, not what it used. After a delete, the script prints the IAM role, Secrets Manager secrets and security groups from the response so you can review them:
- Secrets: the proxy reads database credentials from Secrets Manager. Check nothing else uses them with the script to find unused Secrets Manager secrets.
- IAM role: the role the proxy assumed to read those secrets. Finding unused IAM roles with RoleLastUsed will flag it later.
- Security groups: often created just for the proxy; find unused security groups once the proxy’s network interfaces are gone.
- Extra endpoints: additional proxy endpoints are PrivateLink interface endpoints; the script to find unused VPC endpoints covers similar charges elsewhere.
Update any connection string that still names the proxy endpoint first, or those clients fail on the next deploy. If the database behind an unused proxy is also old, the script to find RDS databases on Extended Support shows whether it carries a second hidden charge. The FinOps Framework treats this kind of recurring cleanup as part of workload optimization.
Troubleshooting
- vCPUs shows “unknown (db.x…)”. The class has no EC2 twin that
DescribeInstanceTypesrecognizes. Look up its vCPUs in the RDS instance class table and multiply by $0.015 × 730. - vCPUs shows “ACU-billed”. The target is Aurora Serverless v2, billed per ACU-hour consumed; check the
RDS:Proxy-ASv2-Usageusage type in Cost Explorer. - All metrics are zero for a busy proxy. Check the Region and
cloudwatch:GetMetricData; also check the app connects through the proxy endpoint, not the instance endpoint. - “–apply needs –proxy”. The script refuses to delete without explicit names, by design.
- AccessDenied on DeleteDBProxy. The ARN in the policy must use
db-proxy:and the resource ID. Troubleshooting AWS IAM access denied errors walks through the message.
Ask ChatWithCloud instead
Ask ChatWithCloud “Which RDS Proxies had no client connections in the last 14 days, and which databases are behind them?” It writes AWS SDK for JavaScript v2 code, runs it locally with your profile and explains the result; how ChatWithCloud answers questions about your AWS account describes the loop. It uses one profile and Region per session, can be wrong and runs changes without asking first, so use ChatWithCloud with a read-only AWS profile and delete proxies yourself. When a bill jumps and you don’t know why, asking AI why your AWS bill increased is a quicker first step. More cleanup scripts are in the AWS practical examples library.
Frequently asked questions
Does an RDS Proxy cost money when nothing connects to it?
Yes. Pricing follows the vCPUs (or Aurora Serverless v2 ACUs) of the databases behind the proxy, not the number of connections, so an unused proxy costs the same as a busy one.
Can you stop an RDS Proxy instead of deleting it?
No. The RDS API has create, modify and delete actions for proxies but no stop action, so deleting is how you end the charge. Recreating the proxy later needs the same secret, IAM role and subnets, so note them before you delete.
Which CloudWatch metric shows RDS Proxy usage?
ClientConnectionsReceived and ClientConnections in AWS/RDS with the ProxyName dimension. QueryRequests helps too, but it doesn’t count PostgreSQL extended-protocol queries.
Does deleting an RDS Proxy affect the database?
No. The database keeps running and clients can connect to its own endpoint. Only clients that still use the proxy endpoint lose their connection.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud