Cloud

Cutting AWS Costs 60% Without Sacrificing Reliability

2024-09-15 Gabor Balogh

AWSCost OptimisationFargate SpotALBFinOps

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.

Back to Blog

About the Author

Gabor Balogh is a senior cloud platform engineer and DevOps consultant building infrastructure-as-code and developer platforms.