Cloud infrastructure

Right-sizing an overprovisioned production PostgreSQL workload

A production database cost about $988 a month while averaging 0.8% CPU and barely using its 30,000 provisioned IOPS. I right-sized compute and storage performance, bringing the RDS bill down to about $320.

Outcome
$988 → $320/moobserved RDS spend, ~68% lower
Read time
6 min
  • AWS RDS
  • PostgreSQL
  • Capacity planning
  • Cloud cost optimization
  • Production risk
  • Graviton

Summary

I audited a production PostgreSQL workload that cost about $988 a month. Plenty of databases justify that price, but this workload didn't come close to using what we paid for. The instance averaged under 1% CPU, held around three database connections, and barely touched its 30,000 provisioned IOPS. Much of the cost came from storage performance, with no evidence that the workload needed it.

I split the bill into compute, storage, IOPS, and throughput, then compared each dimension with the observed telemetry. I chose a conservative Graviton-based instance instead of the mathematically smallest option. Once the migration settled, the RDS line item fell from roughly $987.50 to $319.72 a month. That's about $668, or 68%, lower.

Nobody looks at the bill until it hurts

The database was one of the account's largest individual line items. At the time of the audit, it ran on 8 vCPUs and 32 GB of RAM, with 600 GB of storage, 30,000 provisioned IOPS, and elevated throughput. Any one of those numbers could be reasonable. Together, they needed to be justified by the workload.

I pulled several weeks of CloudWatch metrics and RDS performance insights. The result was clear. Average CPU was 0.8%, and the database handled about three concurrent connections in steady state. Read and write IOPS stayed in the low tens rather than reaching the thousands or tens of thousands. Of the 600 GB allocated, about 93 GB was in use. Freeable memory had a comfortable cushion in every sample window I checked.

The configuration clearly had a lot of capacity I couldn't explain. I still didn't know whether it would be safe to move to the smallest instance suggested by average utilization. That became the most important question in the process.

Why I didn't pick the smallest instance

Sub-1% CPU makes the smallest instance in the family look tempting. I thought choosing it immediately was a bad idea and said so. Averages smooth out the events I cared about. A scheduled job might spike CPU for ninety seconds at 3 AM, and an export might saturate IO throughout a backup window. A database rollback also has different operational risks from restarting a stateless application worker. You could corrupt data or trigger a snapshot restore that takes hours, rather than lose a few requests.

I used the utilization report as a sizing envelope instead of a sizing formula. The metrics showed that the upper bound we were paying for was wrong, but they couldn't establish the absolute lower bound we could safely run.

I split the bill by resource dimension and checked each one against observed peaks as well as averages. The analysis had separate columns for compute, memory, storage capacity, IOPS, and throughput. Teams often anchor sizing discussions on CPU, glance at memory, and ignore IOPS until someone sees the bill.

IOPS was burning more money than compute

The provisioned IOPS stood out. We had 30,000 IOPS provisioned while observed reads and writes stayed in the low tens, which was effectively background noise. RDS bills provisioned IOPS separately from compute at a per-IOPS-per-month rate, and the cost of 30,000 added up quickly. The oversized instance was only part of the problem. We were paying for a level of storage performance the workload hadn't reached since provisioning, and possibly ever.

RDS cost
  =
compute (instance-hour rate)
+ storage (allocated GB-month)
+ provisioned IOPS (per-IOPS-per-month)
+ provisioned throughput (per-MB/s-month)

Reducing IOPS alone would materially lower the bill without changing compute. The reverse was also true: a smaller instance with the same provisioned IOPS would leave a meaningful amount of waste in place. I've seen teams do exactly that. They resize compute, declare victory, then wonder six months later why the database bill is still uncomfortable. Each dimension is independent and needs its own justification.

Storage deserved attention too. Allocating 600 GB for 93 GB of data is a 6x overprovision, and RDS storage costs scale linearly with allocation rather than usage. Storage is harder to change than IOPS, though. You can lower provisioned IOPS with a modification applied during a maintenance window. Depending on the engine version and storage type, reducing allocated storage isn't always straightforward. I recorded that as future work and kept the immediate change focused on an option that had high impact and was reversible.

Why I picked the target I picked

The metrics suggested that a much smaller instance could work. I chose a Graviton-based instance class a couple of steps above that mathematical floor.

I wanted burst headroom because an average below 1% says little about a scheduled analytics query or a traffic increase during deployment. The instance needed enough room to handle those events without falling into swap or exhausting its connections. I also had to account for migration risk. Moving managed PostgreSQL from x86 to Graviton is architecturally straightforward for the application because its clients don't depend on the database server's instruction set. Even so, I wanted to validate extension compatibility and version support before committing. Chasing a smaller instance wasn't worth discovering a broken extension at 2 AM. The workload could grow as well, and cutting too close would put the team back into the same sizing discussion three months later.

I substantially reduced provisioned IOPS and lowered throughput to fit the actual workload profile. The final savings came from the instance change and the IOPS reduction, rather than compute alone.

The bill after the change

I checked the result against the actual bill instead of relying on the cost explorer estimate. Estimates presented internally don't always match the eventual credit card statement. Once the migration settled, the RDS line item was about $319.72 a month, down from $987.50. The reduction was roughly $668, or 68%.

An earlier audit projected savings of more than $900 on RDS alone with a more aggressive approach. That meant choosing the smallest viable instance and cutting every dimension to its observed minimum. I didn't take that route. Production engineering shouldn't be a contest to reach a theoretical minimum. I wanted capacity we could justify, and the final bill showed that we'd achieved it.

For several weeks after the migration, I monitored CPU, freeable memory, connection count, read and write latency, IOPS, queue depth, and storage usage. I also watched application-side p95 and p99 database query latency. I saw no sign that we'd cut too far. The remaining headroom handled the usual spikes without trouble.

What else the audit found

The database work was part of a wider infrastructure review. It found stale resources nobody remembered provisioning, old deployments that still consumed compute, inconsistent security group rules, and replaced services that had never been decommissioned. Infrastructure without a clear owner or lifecycle becomes expensive and hard to reason about during an incident.

The review also changed how I handle destructive infrastructure work. If I'm uncertain about a workload, I stop it before deleting it. I preserve snapshots where needed, change one thing at a time, then verify production behavior and billing after each step. That made the savings understandable and kept rollback options open.

Currently open to the right problem

Building something ambitious that has to actually work?

I'm interested in backend, AI infrastructure, platform, full-stack product, and early engineering roles where ownership is real and reliability matters.

shreyaans20@gmail.com