How we cut infrastructure costs by 35%

Reduce AWS costs without hurting reliability: how our startup cut its bill by 35% with rightsizing, Graviton, Savings Plans, NAT, storage and log fixes.

Our AWS bill didn't jump. It crept. A bigger instance during a launch that nobody sized back down, a staging environment running all weekend, a NAT gateway quietly carrying gigabytes of S3 traffic, log groups that kept everything forever. Each one was small. Together they were more than a third of the bill.

We cut it by 35% in about six weeks, with no downtime and no feature freeze. Nothing below needs a FinOps team: each change took between ten minutes and two days, and they're listed in the order they paid off, with the commands, so you can reduce AWS costs at your startup the same way.

Part of my guide to AWS infrastructure for small SaaS teams.

TL;DR#

  • Find the money first. Group Cost Explorer by service, then by usage type. "EC2-Other" hides NAT gateway traffic, EBS volumes and data transfer.
  • Delete what nobody uses: unattached EBS volumes, old snapshots, idle Elastic IPs and load balancers, and old container images.
  • Rightsize from two weeks of real metrics, including memory, which EC2 doesn't report without the CloudWatch agent.
  • Move to Graviton: about 20% cheaper per hour than the comparable x86 instances, and most Node.js apps run unchanged.
  • Cover the steady baseline with a Savings Plan, sized to your lowest month, not your average.
  • Stop paying the NAT gateway for S3 traffic with a free gateway endpoint.
  • Switch gp2 volumes to gp3, set log retention, and turn staging off at night.

Where the money was going#

You can't cut what you can't see. Before changing anything, we spent an afternoon in Cost Explorer.

Start broad: last month's cost by service.

aws ce get-cost-and-usage \
  --time-period Start=2026-09-01,End=2026-10-01 \
  --granularity MONTHLY \
  --metrics UnblendedCost \
  --group-by Type=DIMENSION,Key=SERVICE

Then group the biggest services by USAGE_TYPE instead of SERVICE. That's where the surprises live. "EC2-Other" splits into EBS storage (EBS:VolumeUsage.gp2), snapshots, data transfer and NAT gateway hours and bytes (NatGateway-Hours, NatGateway-Bytes). Ours had more in NAT gateway bytes than in the database.

Two habits make this easier from now on:

  • Cost allocation tags. Tag every resource with env and service, and activate the tags in the Billing console, so you can see what staging, or one service, really costs. Tags only apply to costs from the day you activate them.
  • One owner. Someone looks at the bill every week. Ten minutes on a Monday catches most of this before it compounds.

The results#

Change Share of the original bill saved
Rightsized instances and the database 9%
Compute Savings Plan on the baseline 7%
Graviton for app servers and RDS 6%
S3 and ECR traffic off the NAT gateway 4%
Deleted unused resources 3%
gp2 to gp3, S3 lifecycle rules 2%
Log retention and log levels 2%
Staging off nights and weekends 2%
Total 35%

Reliability didn't move: the same uptime, the same p95 latency, and the same number of instances in each Availability Zone. The order below is the order we did them in, which isn't the order of the savings: cleanup and rightsizing come before commitments, because you don't want to commit to paying for waste.

1. Delete what nobody uses#

The fastest, safest savings: resources that serve no traffic at all.

# EBS volumes attached to nothing
aws ec2 describe-volumes --filters Name=status,Values=available \
  --query 'Volumes[].{id:VolumeId,gb:Size,type:VolumeType,created:CreateTime}' --output table

# Elastic IPs attached to nothing (every public IPv4 address costs $0.005 an hour)
aws ec2 describe-addresses \
  --query 'Addresses[?AssociationId==null].{ip:PublicIp,id:AllocationId}' --output table

# Your snapshots, oldest first
aws ec2 describe-snapshots --owner-ids self \
  --query 'sort_by(Snapshots,&StartTime)[].{id:SnapshotId,gb:VolumeSize,started:StartTime,desc:Description}' --output table

Also look for load balancers with no healthy targets, RDS instances from old experiments (take a final snapshot, then delete), and container images piling up in ECR. An ECR lifecycle policy keeps the last few dozen images, still enough to roll back:

{
  "rules": [
    {
      "rulePriority": 1,
      "description": "Keep the last 30 images",
      "selection": { "tagStatus": "any", "countType": "imageCountMoreThan", "countNumber": 30 },
      "action": { "type": "expire" }
    }
  ]
}

Since February 2024, AWS charges for every public IPv4 address, attached or not: about $3.65 a month each. Instances in private subnets behind a load balancer don't need one.

2. Rightsize from real metrics#

Most instances are sized for a launch-day guess. We looked at 14 days of metrics for every instance and database and asked one question: what's the p95, not the peak?

  • CPU: under 20% at p95 for two weeks means a smaller size, or a smaller family.
  • Memory: EC2 doesn't report it. Install the CloudWatch agent first (the CloudWatch guide has the config), or you'll downsize a box that's actually short of memory.
  • Burstable instances (t3, t4g) earn CPU credits below a baseline, 20% per vCPU for a t4g.medium. In the default unlimited mode, running above it for long stretches bills surplus credits, which can make a t4g cost more than the m7g you should have picked.

AWS Compute Optimizer is free for EC2 and gives the same answer with less spreadsheet work, once it has memory data.

The database was our single biggest win: an RDS instance sized for a migration months earlier, idling at 8% CPU. One size down, during a quiet hour: with Multi-AZ, RDS resizes the standby first and fails over to it, so the app saw a minute of reconnects rather than an outage.

3. Move to Graviton#

AWS's Arm-based Graviton instances cost about 20% less per hour than the comparable x86 ones (t4g.medium against t3.medium, for example), and usually perform as well or better for Node.js.

For a Node.js app in Docker, the work is mostly in the build:

  • Build multi-architecture images, linux/amd64 and linux/arm64, with Docker Buildx. Building on an arm64 runner (ubuntu-24.04-arm on GitHub Actions) is much faster than emulating it with QEMU.
  • Check native modules. Popular ones like bcrypt, sharp and the Prisma engines ship arm64 builds; anything compiled from source needs its build tools in the image.
  • Move one service at a time, behind the same load balancer, and compare latency and error rates before moving the rest.

RDS runs on Graviton too (db.t4g, db.m7g); the switch is an instance class change with a short failover on Multi-AZ.

4. Commit to the baseline with a Savings Plan#

After cleanup and rightsizing, what's left is your real, steady usage, and that's what a Savings Plan is for. You commit to a dollar amount of compute per hour for one or three years, and AWS discounts that usage.

  • Compute Savings Plans apply across instance families, sizes and Regions, and to Fargate and Lambda. Less discount, much more flexibility. This is the one for a startup.
  • EC2 Instance Savings Plans discount more but lock you to one instance family in one Region.
  • Commit to your floor, not your average. We committed to roughly the lowest hourly spend of the last three months. Anything above it is billed on demand, as before.
  • One year, no upfront keeps the commitment short while the architecture is still changing.
  • RDS isn't covered by Compute Savings Plans. Databases use reserved instances; buy those only after rightsizing.

Cost Explorer's Savings Plans recommendations show the hourly commitment and estimated savings for your actual usage. Do this step last: committing before cleanup locks in paying for waste.

5. Stop paying the NAT gateway for S3 traffic#

A NAT gateway costs $0.045 an hour (about $33 a month) plus $0.045 for every GB it processes in us-east-1. Every container image pulled, every backup uploaded and every file read from S3 by an instance in a private subnet went through ours.

An S3 gateway endpoint is free and takes S3 traffic off the NAT gateway entirely:

aws ec2 create-vpc-endpoint \
  --vpc-id vpc-0abc123def4567890 \
  --vpc-endpoint-type Gateway \
  --service-name com.amazonaws.us-east-1.s3 \
  --route-table-ids rtb-0abc123def4567890 rtb-0def4567890abc123

That covered ECR too: image layers are downloaded from S3, so the largest part of every image pull now skips the NAT gateway. ECR's own API calls still go through it unless you add interface endpoints, which cost about $7 a month each per AZ; worth it only at high pull volumes.

Check the NatGateway-Bytes usage type in Cost Explorer before and after. If it's still high, VPC Flow Logs show which destinations the traffic goes to. VPCs, subnets and security groups explained covers where NAT gateways and endpoints fit.

6. Storage: gp3 volumes and S3 lifecycle rules#

gp2 to gp3. gp3 costs $0.08 per GB-month against gp2's $0.10, and gives every volume a baseline of 3,000 IOPS and 125 MB/s. The change happens in place, without detaching or downtime:

aws ec2 modify-volume --volume-id vol-0abc123def4567890 --volume-type gp3

One catch: gp2 volumes over 1 TB get more than 3,000 IOPS (3 per GB), so provision IOPS on the gp3 volume to match before switching those.

S3 lifecycle rules. Expire old backups and logs, move rarely read objects to Standard-IA or Glacier Instant Retrieval, and abort incomplete multipart uploads after a few days. Those half-finished uploads are invisible in the console and billed like any other storage. For data with unpredictable access, S3 Intelligent-Tiering moves objects between tiers on its own.

7. Logs: retention and levels#

CloudWatch log groups keep everything forever by default. Ingestion costs $0.50 per GB and storage $0.03 per GB-month, so a chatty API at debug level grows a bill every single month.

# Log groups that never expire, with their size
aws logs describe-log-groups \
  --query 'logGroups[?!retentionInDays].[logGroupName,storedBytes]' --output table

aws logs put-retention-policy --log-group-name /api/production --retention-in-days 30

We set 30 days for application logs and 365 for audit logs, moved production to info level, and stopped logging full request bodies, which also kept personal data out of the logs. For logs you only search and never alert on, the Infrequent Access log class halves the ingestion price, at the cost of features like metric filters.

8. Turn staging off at night#

Staging ran 168 hours a week and was used for about 60. EventBridge Scheduler can stop and start instances on a cron schedule without any code, calling the EC2 API directly:

aws scheduler create-schedule \
  --name stop-staging \
  --schedule-expression 'cron(0 20 ? * MON-FRI *)' \
  --schedule-expression-timezone 'Asia/Karachi' \
  --flexible-time-window Mode=OFF \
  --target '{
    "Arn": "arn:aws:scheduler:::aws-sdk:ec2:stopInstances",
    "RoleArn": "arn:aws:iam::123456789012:role/staging-scheduler",
    "Input": "{\"InstanceIds\": [\"i-0abc123def4567890\"]}"
  }'

A matching start-staging schedule at 8 a.m. on weekdays brings it back. That's about 64% less staging compute. RDS instances can be stopped too, for up to seven days at a time, after which AWS starts them again; ECS services scale to zero tasks.

What we didn't do#

  • Spot Instances for the production API. Spot is up to 90% cheaper, and AWS can reclaim it with two minutes' notice. Great for CI runners and batch jobs; not worth the risk for a small team's only API tier.
  • Cut redundancy. Going back to one Availability Zone would have saved money and cost us the 99.9%.
  • Three-year commitments. The bigger discount wasn't worth locking in an architecture we're still changing.

Keep it from creeping back#

  • An AWS Budget per account, with alerts at 80% and 100% of the expected monthly spend.
  • Cost Anomaly Detection, which is free and emails you when a service's spend jumps.
  • Tags required on new resources, so every cost has an owner.
  • A weekly look at Cost Explorer, grouped by usage type, and a quarterly check of rightsizing and Savings Plan coverage.

The checklist#

  • Group last month's bill by service, then by usage type
  • Delete unattached volumes, unused Elastic IPs, old snapshots and idle load balancers
  • Add an ECR lifecycle policy
  • Install the CloudWatch agent and rightsize from 14 days of CPU and memory
  • Move app servers and RDS to Graviton, one service at a time
  • Add an S3 gateway endpoint to every VPC with a NAT gateway
  • Convert gp2 volumes to gp3, matching IOPS on volumes over 1 TB
  • Add S3 lifecycle rules, including aborting incomplete multipart uploads
  • Set retention on every log group and drop debug logging in production
  • Schedule staging to stop outside working hours
  • Buy a one-year Compute Savings Plan sized to your lowest month
  • Turn on budgets, anomaly detection and cost allocation tags

Need a hand?#

An AWS cost review is one of the quickest wins I do for small teams: a few days to find what's safe to cut, and the changes made without downtime. See my DevOps and AWS cloud services, or email mhuzaifaa.dev@gmail.com.

FAQ#

How can a startup reduce AWS costs?#

Start in Cost Explorer: group the bill by service and then by usage type to find the biggest items. Delete unused resources, rightsize instances from two weeks of CPU and memory data, move to Graviton instances, add a free S3 gateway endpoint to keep S3 traffic off the NAT gateway, switch gp2 volumes to gp3, set log retention, and only then buy a Savings Plan for the steady baseline.

What is the fastest way to cut an AWS bill?#

Deleting resources that serve no traffic: unattached EBS volumes, unused Elastic IPs, old snapshots, idle load balancers and forgotten test databases. It takes an afternoon and carries no risk to production. Setting a retention period on CloudWatch log groups and converting gp2 volumes to gp3 are similarly quick and safe.

Are AWS Savings Plans worth it for a startup?#

Yes, for usage you're confident will still be there in a year. A one-year, no-upfront Compute Savings Plan applies across instance types, Regions, Fargate and Lambda, so it survives architecture changes. Commit to your lowest month's hourly spend rather than your average, and buy it after cleanup and rightsizing.

Is switching to Graviton worth it?#

Usually. Graviton instances cost about 20% less per hour than comparable x86 instances, and Node.js, Python, Java and Go apps typically run unchanged. The work is building multi-architecture Docker images and checking native dependencies. Move one service at a time and compare latency and error rates.

Why is my NAT gateway so expensive?#

A NAT gateway charges per hour and per GB processed, $0.045 each in us-east-1, so every byte instances in private subnets download through it costs extra, including S3 objects and container image layers. A free S3 gateway endpoint takes S3 and most ECR traffic off the NAT gateway. VPC Flow Logs show where the rest goes.

Muhammad Huzaifa Abdulahad
Written by

Muhammad Huzaifa Abdulahad

Full-Stack Software & DevOps Engineer

I build SaaS platforms with Node.js, NestJS, TypeScript and PostgreSQL, and run them on Linux, Docker, AWS and CI/CD — from the first line of code to production.

Related articles

See all posts →

Have a complex idea? Let's build it together.

From MVPs to scalable products, let's turn your idea into reliable software built for real-world impact.