Cutting AWS Costs 60% Without Sacrificing Reliability
Cloud cost optimisation articles tend to be vague. They say things like "right-size your instances" and "use reserved capacity" without telling you what that actually looked like in practice. This post is different — it covers the specific changes, the reasoning behind each one, and the actual cost impact.
Over three months, the AWS bill dropped from ~$380/month to ~$150/month. Here's exactly how.
Starting Point: Where the Money Was Going
Before optimising anything, visibility is essential. AWS Cost Explorer with daily granularity and grouping by service gives you the breakdown:
| Service | Before | % of bill |
|---|---|---|
| ECS Fargate | $180 | 47% |
| ALB | $95 | 25% |
| RDS | $60 | 16% |
| Other | $45 | 12% |
Two services (Fargate and ALB) accounted for 72% of the bill. That's where to focus.
Change 1: Fargate Spot for Non-Production (Saved ~$90/month)
Fargate Spot uses spare AWS capacity and costs 70% less than on-demand Fargate. The tradeoff: tasks can be interrupted with a 2-minute warning when AWS needs the capacity back.
For production workloads, this is unacceptable. But for dev and test environments that run 9-to-5 on weekdays? A 2-minute interruption followed by an automatic ECS task replacement is entirely fine. Developers are used to eventual consistency.
In Pulumi TypeScript, the change was four lines:
// Before: on-demand only
capacityProviderStrategies: [{
capacityProvider: 'FARGATE',
weight: 1
}],
// After: Spot with on-demand fallback
capacityProviderStrategies: [
{ capacityProvider: 'FARGATE_SPOT', weight: 4, base: 0 },
{ capacityProvider: 'FARGATE', weight: 1, base: 1 } // 1 on-demand always available
],
The base: 1 on FARGATE ensures at least one on-demand task is always running. This prevents a complete outage if Spot capacity is briefly unavailable. The weight ratio means Fargate Spot gets 80% of tasks beyond the base.
Cost impact: Dev environment: $45/month → $14/month. Test environment: $38/month → $12/month.
Scheduling Non-Production Downtime
Dev and test environments don't need to run at 3am. Add an ECS scheduled scaling policy to scale to zero tasks overnight and on weekends:
new aws.appautoscaling.ScheduledAction('scale-down-nights', {
resourceId: `service/${cluster.name}/${service.name}`,
scalableDimension: 'ecs:service:DesiredCount',
serviceNamespace: 'ecs',
schedule: 'cron(0 19 ? * MON-FRI *)', // 7pm UTC Mon-Fri
scalableTargetAction: { minCapacity: 0, maxCapacity: 0 },
});
new aws.appautoscaling.ScheduledAction('scale-up-mornings', {
resourceId: `service/${cluster.name}/${service.name}`,
scalableDimension: 'ecs:service:DesiredCount',
serviceNamespace: 'ecs',
schedule: 'cron(0 7 ? * MON-FRI *)', // 7am UTC Mon-Fri
scalableTargetAction: { minCapacity: 1, maxCapacity: 3 },
});
This eliminates 69 hours of compute per week (evenings + weekends). Additional saving: ~$20/month per environment.
Change 2: Shared ALB with Path-Based Routing (Saved ~$70/month)
The original setup: one ALB per environment (dev, test, prod). Each ALB costs ~$22.50/month minimum (the fixed hourly charge), plus LCU usage.
Three ALBs = $67.50/month minimum, before any traffic.
The fix: consolidate to one ALB with path-based routing and host-based routing. An ALB can have multiple listeners and multiple target groups. The HTTP/HTTPS listener rules route based on hostname:
// Single ALB, multiple listener rules
new aws.lb.ListenerRule('website-prod', {
listenerArn: httpsListener.arn,
priority: 10,
actions: [{ type: 'forward', targetGroupArn: websiteProdTg.arn }],
conditions: [{ hostHeader: { values: ['gabaltech.co.uk', 'www.gabaltech.co.uk'] } }],
});
new aws.lb.ListenerRule('website-dev', {
listenerArn: httpsListener.arn,
priority: 20,
actions: [{ type: 'forward', targetGroupArn: websiteDevTg.arn }],
conditions: [{ hostHeader: { values: ['dev.gabaltech.co.uk'] } }],
});
Three ALBs became one. ALB cost: $67.50/month → ~$5/month (single ALB, low traffic).
One ACM certificate with SANs for all domains replaced three separate certificates. ACM certificates are free — but managing three vs one is three times the operational overhead.
Change 3: RDS Right-Sizing (Saved ~$35/month)
A db.t3.small RDS instance (PostgreSQL) perpetually at 8% CPU utilisation costs ~$60/month in eu-west-2.
The first question to ask: is RDS actually needed? For use cases like session storage and configuration data, DynamoDB PAY_PER_REQUEST is often a better fit. At low query volumes (hundreds per day, not millions), DynamoDB costs $0.00 per month within the free tier.
Migrating from RDS to DynamoDB requires data model changes (straightforward if the application uses simple key-value lookups) and roughly a week of engineering work. Monthly saving: $60/month, offset by ~$25/month DynamoDB cost at higher traffic volumes = net $35/month.
For workloads that genuinely need a relational database: consider Aurora Serverless v2. It scales to zero when idle and costs nothing during inactivity, unlike provisioned RDS which runs 24/7 regardless of load.
Change 4: Eliminating Idle Elastic IPs
Four Elastic IPs allocated from a previous NAT Gateway setup that was decommissioned. Unattached EIPs cost $0.005/hour each — $14.40/month for four doing nothing.
# Find unattached EIPs
aws ec2 describe-addresses \
--query 'Addresses[?AssociationId==`null`].[AllocationId,PublicIp]' \
--output table --region eu-west-2
Releasing all four: $14.40/month saved. Worth checking: how long have yours been sitting idle?
Change 5: CloudWatch Log Retention
Default CloudWatch log retention is never expire. Every byte of logs is retained forever, charged at $0.57/GB per month for storage.
Set explicit retention policies on all log groups:
new aws.cloudwatch.LogGroup('ecs-website', {
name: '/ecs/website',
retentionInDays: 30, // prod: 30 days
tags: { environment: 'prod', managedBy: 'pulumi' },
});
new aws.cloudwatch.LogGroup('ecs-website-dev', {
name: '/ecs/website-dev',
retentionInDays: 7, // dev: 7 days
});
At our log volume, this wasn't a massive saving — about $4/month — but it's the kind of thing that compounds as services scale.
The Architecture Principle: Build for Zero Idle Cost
The theme across all these changes is the same: charge only when you're delivering value.
- Fargate Spot: cheaper compute, automatic fallback
- Scheduled scaling: zero tasks = zero cost during off-hours
- Shared ALB: pay once for the load balancer, route to many services
- DynamoDB PAY_PER_REQUEST: zero queries = zero cost
- Log retention: don't pay to store data you'll never read
The changes that don't require code or architecture changes — idle EIPs, log retention — take one afternoon. The bigger wins (Spot, shared ALB, RDS migration) require a week or two of engineering time each. The total investment is roughly three weeks of part-time engineering work.
At $230/month saved, the engineering cost pays for itself in the first month.
Summary
| Change | Monthly Saving |
|---|---|
| Fargate Spot + scheduled scaling | ~$90 |
| Shared ALB | ~$70 |
| RDS → DynamoDB | ~$35 |
| Idle EIP cleanup | ~$14 |
| CloudWatch log retention | ~$4 |
| Total | ~$213/month |
The remaining bill is primarily the shared ALB, one Fargate task for production (on-demand, always running), Route53 hosted zones, and ACM. All of these deliver direct value — nothing idle.