Cut AWS Costs 81%: A Practical Guide
Keeping an early-stage app running without burning money.
The Problem
CompStacker is a real estate investment analysis platform. It’s a full-stack app with a Kotlin/Spring Boot backend, React frontend, and PostgreSQL database—all running on AWS.
The problem? It was costing $140/month before generating any revenue.
That’s not sustainable for an early-stage product. The goal was to get costs under control while keeping the app fully functional.
Goal: Cut costs by 50%+ without compromising functionality.
Result: Reduced from ~$140/month to ~$27/month (81% savings).
The Cost Breakdown
Before optimizing, here’s where the money was going:
| Service | Monthly Cost |
|---|---|
| Aurora Serverless v2 | ~$50 |
| ECS Fargate (512 CPU / 1GB) | ~$36 |
| Application Load Balancer | ~$16 |
| CloudFront, S3, misc | ~$16 |
| Total | ~$140 |
The ALB was surprising. Aurora being expensive was expected, but $16/month for a load balancer serving essentially zero traffic? That’s a fixed cost regardless of usage.
Change #1: Switch to Fargate Spot
Savings: ~$40/month
Fargate Spot instances run on spare AWS capacity at 50-70% off regular Fargate pricing. The tradeoff is that AWS can reclaim them with 2 minutes notice. For an early-stage app without strict uptime SLAs, that’s an acceptable risk.
const backendService = new ecs.FargateService(this, "BackendService", {
cluster,
taskDefinition: backendTaskDefinition,
capacityProviderStrategies: [
{ capacityProvider: "FARGATE_SPOT", weight: 1, base: 1 },
],
// ...
});
Combined with ARM64/Graviton processors (20% cheaper than x86), Fargate costs dropped from ~$36/month to ~$18/month.
Change #2: Downsize Fargate
Savings: ~$9/month
The Fargate task was running with 512 CPU units and 1GB memory. Looking at CloudWatch metrics, actual usage was:
- CPU: average 0.4%, peak 33%
- Memory: ~400MB peak
For an early-stage app with minimal traffic, this was overkill.
// Before
const backendTaskDefinition = new ecs.FargateTaskDefinition(this, "BackendTaskDef", {
memoryLimitMiB: 1024,
cpu: 512,
// ...
});
// After
const backendTaskDefinition = new ecs.FargateTaskDefinition(this, "BackendTaskDef", {
memoryLimitMiB: 512,
cpu: 256, // Minimum Fargate size
// ...
});
This was the last Fargate optimization available without changing architecture.
Change #3: Replace ALB with API Gateway HTTP API
Savings: ~$15/month
This was the bigger win. ALBs have a fixed hourly cost (~$16/month minimum) regardless of traffic. For a low-traffic app, that’s wasteful.
API Gateway HTTP API, on the other hand, charges per request—about $1 per million requests. At low traffic levels, that’s essentially free.
The Challenge
You can’t just point CloudFront directly at a Fargate task. CloudFront needs an origin, and Fargate tasks don’t have stable DNS names.
The Solution: API Gateway + VPC Link + Cloud Map
Here’s the architecture change:
Before: CloudFront → ALB ($16/mo) → Fargate
After: CloudFront → API Gateway HTTP ($1/mo) → VPC Link → Cloud Map → Fargate
The implementation required several CDK changes:
1. Create a Cloud Map namespace for service discovery:
const cloudMapNamespace = new servicediscovery.PrivateDnsNamespace(
this, "ServiceNamespace", {
name: `${envPrefix}.local`,
vpc,
}
);
2. Replace ALB-based Fargate service with simple Fargate + Cloud Map:
// Before: ApplicationLoadBalancedFargateService (includes ALB)
// After: Simple FargateService with Cloud Map registration
const backendService = new ecs.FargateService(this, "BackendService", {
cluster,
taskDefinition: backendTaskDefinition,
desiredCount: 1,
assignPublicIp: true,
securityGroups: [backendSecurityGroup],
vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC },
capacityProviderStrategies: [
{ capacityProvider: "FARGATE_SPOT", weight: 1, base: 1 },
],
cloudMapOptions: {
name: "backend",
cloudMapNamespace: cloudMapNamespace,
dnsRecordType: servicediscovery.DnsRecordType.SRV,
containerPort: 8080,
},
});
3. Create VPC Link for API Gateway:
const vpcLink = new apigatewayv2.VpcLink(this, "ApiVpcLink", {
vpc,
subnets: { subnetType: ec2.SubnetType.PUBLIC },
securityGroups: [backendSecurityGroup],
});
4. Create HTTP API with service discovery integration:
const httpApi = new apigatewayv2.HttpApi(this, "BackendHttpApi", {
apiName: `${envPrefix}-api`,
corsPreflight: {
allowOrigins: [`https://${props.domainName}`],
allowMethods: [CorsHttpMethod.ANY],
allowHeaders: ["*"],
allowCredentials: true,
},
});
const backendIntegration = new apigatewayv2_integrations.HttpServiceDiscoveryIntegration(
"BackendIntegration",
backendService.cloudMapService!,
{ vpcLink }
);
httpApi.addRoutes({
path: "/{proxy+}",
methods: [apigatewayv2.HttpMethod.ANY],
integration: backendIntegration,
});
5. Update CloudFront to use API Gateway:
additionalBehaviors: {
"/api/*": {
origin: new origins.HttpOrigin(
`${httpApi.apiId}.execute-api.${this.region}.amazonaws.com`,
{ protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY }
),
// ... caching disabled, all methods allowed
},
}
What Didn’t Change (Yet)
Aurora Serverless v2 → RDS db.t4g.micro
This would save another ~$38/month, bringing the total to under $15/month. Aurora Serverless v2 is great for variable workloads, but for an early-stage product with predictable usage, a simple RDS instance is plenty.
This change is deferred because:
- It requires data migration (pg_dump/pg_restore)
- The current changes already hit the target
- Better to validate the API Gateway setup first
Final Numbers
| Service | Before | After | Savings |
|---|---|---|---|
| Aurora Serverless | $50 | $50 | $0 (deferred) |
| Fargate | $36 | $9 | $27 |
| ALB → API Gateway | $16 | $1 | $15 |
| Other | $16 | $12 | $4 |
| Total | $140 | $72 | $68 |
With the database migration (a future optimization), the final cost would be:
| Service | Cost |
|---|---|
| RDS db.t4g.micro | $12 |
| Fargate (256/512, Spot) | $9 |
| API Gateway HTTP | $1 |
| CloudFront/S3/misc | $5 |
| Total | ~$27/month |
Lessons Learned
-
ALBs are expensive for low traffic. If you’re not using ALB features (multiple target groups, advanced routing, WAF integration), API Gateway HTTP API is significantly cheaper.
-
Right-size from the start. Over-provisioning “just in case” is tempting but wasteful. For early-stage products, start small and scale up if needed.
-
Fargate Spot is free money. 50-70% cheaper than regular Fargate, and for non-critical workloads, the occasional interruption is fine.
-
Aurora Serverless v2 minimum is still expensive. Even at 0.5 ACU minimum, you’re paying ~$40-50/month. For consistent, low workloads, standard RDS is cheaper.
-
CDK makes this possible. Refactoring from ALB to API Gateway would be painful with manual console changes. With CDK, it’s a code change and a deploy.
The Code
The full infrastructure is in CDK/TypeScript. The changes described here are about 100 lines of CDK code changes total.
Running an early-stage product shouldn’t cost a car payment. With some architecture tweaks, you can keep your app live while you grow revenue.