AWS Cloud Cost Optimization 2026: Cut Your Bill by 60% Without Killing Performance

Sanjeev SharmaSanjeev Sharma
10 min read

Advertisement

AWS Cost Optimization 2026: Stop Burning Money in the Cloud

The average company wastes 32% of its cloud spend. AWS bills grow silently — dev environments left running, over-provisioned instances, unused Elastic IPs, data transfer charges nobody noticed. Here is how to find and eliminate every dollar of waste.

The Cost Visibility Stack

Before you optimize, you need to see where money is going:

# Enable Cost Explorer (free)
# AWS Console → Billing → Cost Explorer → Enable

# AWS CLI: Get monthly costs by service
aws ce get-cost-and-usage \
  --time-period Start=2026-03-01,End=2026-03-31 \
  --granularity MONTHLY \
  --metrics "UnblendedCost" \
  --group-by Type=DIMENSION,Key=SERVICE \
  --query 'ResultsByTime[0].Groups[*].{Service:Keys[0],Cost:Metrics.UnblendedCost.Amount}' \
  --output table

# Get costs by tag (requires cost allocation tags enabled)
aws ce get-cost-and-usage \
  --time-period Start=2026-03-01,End=2026-03-31 \
  --granularity MONTHLY \
  --metrics "UnblendedCost" \
  --group-by Type=TAG,Key=Environment \
  --output json
// cost-reporter.ts — Weekly cost report to Slack
import { CostExplorerClient, GetCostAndUsageCommand } from '@aws-sdk/client-cost-explorer'

const client = new CostExplorerClient({ region: 'us-east-1' })

async function getWeeklyCosts() {
  const end = new Date()
  const start = new Date()
  start.setDate(start.getDate() - 7)

  const { ResultsByTime } = await client.send(new GetCostAndUsageCommand({
    TimePeriod: {
      Start: start.toISOString().split('T')[0],
      End: end.toISOString().split('T')[0],
    },
    Granularity: 'DAILY',
    Metrics: ['UnblendedCost'],
    GroupBy: [{ Type: 'DIMENSION', Key: 'SERVICE' }],
  }))

  const totals: Record<string, number> = {}

  ResultsByTime?.forEach(day => {
    day.Groups?.forEach(group => {
      const service = group.Keys?.[0] ?? 'Unknown'
      const cost = parseFloat(group.Metrics?.UnblendedCost?.Amount ?? '0')
      totals[service] = (totals[service] ?? 0) + cost
    })
  })

  // Sort by cost descending
  return Object.entries(totals)
    .sort((a, b) => b[1] - a[1])
    .filter(([, cost]) => cost > 1)  // Only show >$1
}

async function sendSlackReport() {
  const costs = await getWeeklyCosts()
  const total = costs.reduce((sum, [, cost]) => sum + cost, 0)

  const lines = costs.slice(0, 10).map(([service, cost]) =>
    `${service}: $${cost.toFixed(2)}`
  )

  const message = `*AWS Weekly Cost Report*\n*Total: $${total.toFixed(2)}*\n\n${lines.join('\n')}`

  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: 'POST',
    body: JSON.stringify({ text: message }),
  })
}

Reserved Instances vs Savings Plans

Commit to usage for 1-3 years and save 40-75%:

Reserved Instances (RIs):
- Locked to specific instance type + region + OS
- Up to 75% savings vs On-Demand
- Best for: Stable workloads, specific instance needs

Savings Plans:
- Flexible across instance families, regions, OS
- Up to 66% savings
- Two types:
  1. Compute Savings Plans — most flexible (EC2, Lambda, Fargate)
  2. EC2 Instance Savings Plans — higher savings, locked to family

Recommendation for 2026:
- Use Compute Savings Plans for flexibility
- Start with 1-year, no upfront (zero risk)
- Buy to cover 70-80% of baseline, let rest be On-Demand
# Find RI/Savings Plan recommendations via CLI
aws ce get-savings-plans-purchase-recommendation \
  --savings-plans-type COMPUTE_SP \
  --term-in-years ONE_YEAR \
  --payment-option NO_UPFRONT \
  --lookback-period-in-days THIRTY_DAYS

# Check RI utilization
aws ce get-reservation-utilization \
  --time-period Start=2026-03-01,End=2026-03-31

# See Savings Plans coverage
aws ce get-savings-plans-coverage \
  --time-period Start=2026-03-01,End=2026-03-31 \
  --granularity MONTHLY

Spot Instances: 90% Off for Fault-Tolerant Workloads

Spot Instances use spare AWS capacity at massive discounts. They can be interrupted with 2 minutes notice — so handle interruptions gracefully:

// spot-handler.ts — Handle Spot interruption notices
import http from 'http'

// Poll EC2 instance metadata every 5 seconds
async function checkSpotInterruption() {
  return new Promise<boolean>((resolve) => {
    const req = http.get(
      'http://169.254.169.254/latest/meta-data/spot/termination-time',
      (res) => resolve(res.statusCode === 200)
    )
    req.on('error', () => resolve(false))
    req.setTimeout(1000, () => {
      req.destroy()
      resolve(false)
    })
  })
}

async function gracefulShutdown() {
  console.log('Spot interruption detected — graceful shutdown starting')

  // Stop accepting new work
  isShuttingDown = true

  // Finish current tasks (you have ~90 seconds)
  await drainQueue()
  await db.disconnect()

  // Deregister from load balancer
  await deregisterFromALB()

  console.log('Shutdown complete')
  process.exit(0)
}

// Check every 5 seconds
setInterval(async () => {
  const interrupted = await checkSpotInterruption()
  if (interrupted) await gracefulShutdown()
}, 5000)
# Auto Scaling Group with Spot + On-Demand mix
# cloudformation-asg.yml
Resources:
  AppASG:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      MixedInstancesPolicy:
        InstancesDistribution:
          OnDemandBaseCapacity: 1          # Always 1 On-Demand
          OnDemandPercentageAboveBaseCapacity: 20  # 20% On-Demand, 80% Spot
          SpotAllocationStrategy: capacity-optimized  # Fewest interruptions
        LaunchTemplate:
          LaunchTemplateSpecification:
            LaunchTemplateId: !Ref LaunchTemplate
            Version: !GetAtt LaunchTemplate.LatestVersionNumber
          Overrides:
            - InstanceType: t3.large
            - InstanceType: t3a.large   # AMD variant
            - InstanceType: m5.large    # Fallback
            - InstanceType: m5a.large

Right-Sizing EC2 Instances

Oversized instances are the #1 waste. Use AWS Compute Optimizer:

# Enable Compute Optimizer (free)
aws compute-optimizer update-enrollment-status --status Active

# Get EC2 recommendations
aws compute-optimizer get-ec2-instance-recommendations \
  --query 'instanceRecommendations[*].{
    Instance:instanceArn,
    CurrentType:currentInstanceType,
    RecommendedType:recommendationOptions[0].instanceType,
    Savings:recommendationOptions[0].estimatedMonthlySavings.value
  }' \
  --output table

# Right-sizing script — check underutilized instances
aws cloudwatch get-metric-statistics \
  --namespace AWS/EC2 \
  --metric-name CPUUtilization \
  --dimensions Name=InstanceId,Value=i-1234567890abcdef0 \
  --start-time 2026-03-01T00:00:00Z \
  --end-time 2026-03-26T00:00:00Z \
  --period 86400 \
  --statistics Average \
  --query 'Datapoints[*].Average'
# find-idle-instances.py
import boto3
from datetime import datetime, timedelta

ec2 = boto3.client('ec2', region_name='us-east-1')
cloudwatch = boto3.client('cloudwatch', region_name='us-east-1')

def get_average_cpu(instance_id: str, days: int = 14) -> float:
    end = datetime.utcnow()
    start = end - timedelta(days=days)

    response = cloudwatch.get_metric_statistics(
        Namespace='AWS/EC2',
        MetricName='CPUUtilization',
        Dimensions=[{'Name': 'InstanceId', 'Value': instance_id}],
        StartTime=start,
        EndTime=end,
        Period=86400,
        Statistics=['Average'],
    )

    if not response['Datapoints']:
        return 0.0
    return sum(d['Average'] for d in response['Datapoints']) / len(response['Datapoints'])

# Find instances with <5% average CPU
paginator = ec2.get_paginator('describe_instances')
idle_instances = []

for page in paginator.paginate(Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]):
    for reservation in page['Reservations']:
        for instance in reservation['Instances']:
            instance_id = instance['InstanceId']
            avg_cpu = get_average_cpu(instance_id)

            if avg_cpu < 5.0:
                name = next(
                    (tag['Value'] for tag in instance.get('Tags', []) if tag['Key'] == 'Name'),
                    'Unnamed'
                )
                idle_instances.append({
                    'id': instance_id,
                    'name': name,
                    'type': instance['InstanceType'],
                    'avg_cpu': avg_cpu,
                })

for inst in sorted(idle_instances, key=lambda x: x['avg_cpu']):
    print(f"{inst['name']} ({inst['id']}) — {inst['type']}{inst['avg_cpu']:.1f}% CPU")

S3 Cost Optimization

S3 storage and data transfer are sneaky cost sources:

# Analyze S3 bucket sizes and storage classes
aws s3api list-buckets --query 'Buckets[*].Name' --output text | \
  tr '\t' '\n' | while read bucket; do
    echo "=== $bucket ==="
    aws s3api list-objects-v2 --bucket "$bucket" \
      --query '{
        Total: length(Contents),
        Size: sum(Contents[*].Size)
      }' --output json 2>/dev/null || echo "Access denied"
  done
{
  "Rules": [
    {
      "ID": "auto-tiering",
      "Status": "Enabled",
      "Transitions": [
        {
          "Days": 30,
          "StorageClass": "STANDARD_IA"
        },
        {
          "Days": 90,
          "StorageClass": "GLACIER_IR"
        },
        {
          "Days": 365,
          "StorageClass": "DEEP_ARCHIVE"
        }
      ],
      "NoncurrentVersionTransitions": [
        {
          "NoncurrentDays": 7,
          "StorageClass": "STANDARD_IA"
        },
        {
          "NoncurrentDays": 30,
          "StorageClass": "GLACIER"
        }
      ],
      "NoncurrentVersionExpiration": {
        "NoncurrentDays": 90
      }
    }
  ]
}
# Enable S3 Intelligent-Tiering for unpredictable access patterns
aws s3api put-bucket-intelligent-tiering-configuration \
  --bucket my-bucket \
  --id EntireBucket \
  --intelligent-tiering-configuration '{
    "Id": "EntireBucket",
    "Status": "Enabled",
    "Tierings": [
      {"Days": 90, "AccessTier": "ARCHIVE_ACCESS"},
      {"Days": 180, "AccessTier": "DEEP_ARCHIVE_ACCESS"}
    ]
  }'

Lambda Cost Optimization

Lambda is cheap but can surprise you at scale:

// Lambda cost factors:
// Requests: $0.20 per 1M
// Duration: $0.0000166667 per GB-second (arm64)
// Memory: Higher memory = faster execution = same or lower cost

// Optimize: Use arm64 (20% cheaper)
// In serverless.yml:
// architecture: arm64

// Profile memory usage to find optimal size
// Lambda Power Tuning: https://github.com/alexcasalboni/aws-lambda-power-tuning

// Reduce cold starts: minimize package size
// Check bundle size after build:
// du -sh .serverless/webcoderspeed-api.zip

// Use Lambda SnapStart (Java) or keep functions warm

// Avoid Lambda for:
// - >15 minute tasks (use ECS Fargate instead)
// - Constant high traffic >50M req/month (EC2 cheaper)
// - Tasks needing >10GB memory (use EC2)
# lambda-cost-calculator.py
def calculate_lambda_cost(
    monthly_requests: int,
    avg_duration_ms: float,
    memory_mb: int,
    architecture: str = 'arm64'
) -> dict:
    # Pricing (arm64)
    request_price = 0.20 / 1_000_000  # per request

    # arm64: $0.0000133334 per GB-second
    # x86:   $0.0000166667 per GB-second
    gb_second_price = 0.0000133334 if architecture == 'arm64' else 0.0000166667

    # Free tier: 1M requests + 400,000 GB-seconds
    FREE_REQUESTS = 1_000_000
    FREE_GB_SECONDS = 400_000

    billable_requests = max(0, monthly_requests - FREE_REQUESTS)

    duration_seconds = avg_duration_ms / 1000
    gb_seconds = (memory_mb / 1024) * duration_seconds * monthly_requests
    billable_gb_seconds = max(0, gb_seconds - FREE_GB_SECONDS)

    request_cost = billable_requests * request_price
    compute_cost = billable_gb_seconds * gb_second_price
    total = request_cost + compute_cost

    return {
        'requests': f'${request_cost:.4f}',
        'compute': f'${compute_cost:.4f}',
        'total': f'${total:.4f}',
        'vs_ec2_t3_small': f'{"CHEAPER" if total < 15 else "EC2 cheaper"} than EC2 t3.small ($15/mo)',
    }

# Example: 5M requests, 200ms avg, 512MB, arm64
print(calculate_lambda_cost(5_000_000, 200, 512))
# {'requests': '$0.8000', 'compute': '$1.3333', 'total': '$2.1333', ...}

RDS Cost Optimization

# Aurora Serverless v2 — scales to zero, pay per ACU
# aurora-serverless.tf
resource "aws_rds_cluster" "serverless" {
  cluster_identifier      = "my-app-db"
  engine                  = "aurora-postgresql"
  engine_mode             = "provisioned"  # v2 uses provisioned mode
  engine_version          = "15.4"
  database_name           = "myapp"
  master_username         = "postgres"
  master_password         = var.db_password
  skip_final_snapshot     = false

  serverlessv2_scaling_configuration {
    min_capacity = 0.5   # Minimum 0.5 ACU (scales to near-zero)
    max_capacity = 16    # Maximum 16 ACU
  }
}

resource "aws_rds_cluster_instance" "serverless" {
  cluster_identifier = aws_rds_cluster.serverless.id
  instance_class     = "db.serverless"
  engine             = aws_rds_cluster.serverless.engine
}
# Identify idle RDS instances
aws rds describe-db-instances \
  --query 'DBInstances[*].{ID:DBInstanceIdentifier,Class:DBInstanceClass,Status:DBInstanceStatus,MultiAZ:MultiAZ}' \
  --output table

# Stop dev/staging RDS when not in use (saves 100%)
aws rds stop-db-instance --db-instance-identifier dev-db

# Schedule auto-stop/start with EventBridge
# Stop at 8pm, start at 8am weekdays

Quick Wins Checklist

OptimizationTypical SavingsEffort
Delete unused Elastic IPs$3.65/IP/monthLow
Delete unattached EBS volumes$0.08/GB/monthLow
Remove unused Load Balancers$16/month eachLow
Right-size oversized EC220-60%Medium
Spot Instances for batch jobs70-90%Medium
Reserved Instances / Savings Plans40-70%Low
S3 Lifecycle Policies50-90% on old dataLow
NAT Gateway → VPC EndpointsVariesMedium
Compress data before S3 upload60-80% storageMedium
CloudFront for S3 (reduce S3 GET)VariesMedium
# Find and delete unattached EBS volumes
aws ec2 describe-volumes \
  --filters Name=status,Values=available \
  --query 'Volumes[*].{ID:VolumeId,Size:Size,Type:VolumeType,Created:CreateTime}' \
  --output table

# Find unused Elastic IPs
aws ec2 describe-addresses \
  --query 'Addresses[?AssociationId==`null`].{IP:PublicIp,AllocationId:AllocationId}' \
  --output table

# Find old EBS snapshots (>30 days, not attached to AMI)
aws ec2 describe-snapshots --owner-ids self \
  --query 'Snapshots[?StartTime<=`2026-02-26`].{ID:SnapshotId,Size:VolumeSize,Date:StartTime}' \
  --output table

FinOps: Cost as a Culture

Cost optimization is not a one-time project — it is a culture:

  1. Tag everything — every resource must have Environment, Team, Project tags
  2. Set budget alerts — get notified at 80% and 100% of monthly budget
  3. Weekly cost reviews — make it a team ritual, not an audit
  4. Cost as a team KPI — include cloud cost per feature in engineering metrics
  5. Use AWS Budgets Actions — auto-stop resources when budget exceeded
# Create a budget with email + SNS alerts
aws budgets create-budget \
  --account-id 123456789012 \
  --budget '{
    "BudgetName": "MonthlyBudget",
    "BudgetLimit": {"Amount": "500", "Unit": "USD"},
    "TimeUnit": "MONTHLY",
    "BudgetType": "COST"
  }' \
  --notifications-with-subscribers '[{
    "Notification": {
      "NotificationType": "ACTUAL",
      "ComparisonOperator": "GREATER_THAN",
      "Threshold": 80
    },
    "Subscribers": [{"SubscriptionType": "EMAIL", "Address": "devops@company.com"}]
  }]'

The goal is not to spend the least — it is to get the most value per dollar. A well-optimized AWS bill reflects a well-architected system.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro