LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Cloud Cost Engineering: FinOps Without the Buzzwords

awscloudcost-optimizationdevopsinfrastructure-as-codefinops

Studies consistently find that 30–40% of cloud spend is wasted: overprovisioned instances nobody has resized, stopped EC2 instances accumulating EBS charges, NAT Gateways processing gigabytes of traffic that could route through free VPC endpoints, development environments running on nights and weekends, storage buckets with data nobody has accessed in three years. The tooling to find and fix this has improved significantly, but the tooling is not the hard part.

The hard part is organizational. Cloud cost optimization requires someone with the authority to change things, the data to know what to change, and the processes to prevent the waste from accumulating again. Without all three, you run a cost review, make some changes, declare victory, and six months later the bill is back where it started.

This post covers the technical mechanics of AWS cost reduction — commitment discounts, rightsizing, idle resource cleanup, data transfer, storage tiering — and the organizational practices that make the savings stick.


Understand Your Bill Before Optimizing It

The single most common mistake in cost optimization is jumping to solutions before understanding the actual cost distribution. “We should buy Reserved Instances” is not a useful insight; “our production RDS cluster runs 24/7 and costs $4,200/month at on-demand pricing; three-year no-upfront Savings Plans would save $2,400/month” is.

Cost Explorer fundamentals

AWS Cost Explorer (Billing → Cost Explorer) is free and provides 13 months of cost history. The filters and grouping dimensions that matter most for initial analysis:

Group by: Service         → find which services drive the bill
Group by: Usage Type      → find what within a service (data transfer, instance-hours, storage)
Group by: Tag (team/env)  → find which teams or environments spend the most
Filter: Charge Type = Usage → exclude credits, fees, taxes for clean comparison

For EC2 specifically, break down by:

  • Instance type: which types and families are running most hours
  • Purchase option: on-demand vs reserved vs spot vs savings plan (shows commitment coverage gaps)
  • Region: identify unexpected cross-region activity

Save useful filter configurations as Cost Explorer bookmarks. The analysis you run during this cost review is the same one you will run monthly — save it once.

Cost and Usage Report for deep analysis

Cost Explorer covers 90% of use cases. When you need more granular data (per-resource-ID breakdown, hourly granularity, custom dimensions), enable the Cost and Usage Report (CUR):

Billing → Cost and Usage Reports → Create report
  Report name: cur-hourly
  Include resource IDs: checked
  Delivery: S3 bucket in your account
  Format: Parquet (for Athena queries)
  Granularity: Hourly

Query CUR with Athena:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- Top 20 resources by cost last month
SELECT
  line_item_resource_id,
  line_item_product_code,
  SUM(line_item_unblended_cost) AS total_cost
FROM "cur"."cur_hourly"
WHERE year = '2026' AND month = '4'
  AND line_item_line_item_type = 'Usage'
  AND line_item_unblended_cost > 0
GROUP BY 1, 2
ORDER BY 3 DESC
LIMIT 20;

-- Data transfer costs by type
SELECT
  line_item_usage_type,
  SUM(line_item_unblended_cost) AS cost,
  SUM(line_item_usage_amount) AS gb_transferred
FROM "cur"."cur_hourly"
WHERE year = '2026' AND month = '4'
  AND line_item_product_code = 'AmazonEC2'
  AND line_item_usage_type LIKE '%DataTransfer%'
GROUP BY 1
ORDER BY 2 DESC;

This gives you the specific resource IDs costing the most, which is what you need to act on rather than aggregate service totals.


Commitment Discounts

The single highest-leverage cost action for stable workloads is buying commitment discounts. AWS on-demand pricing is deliberately expensive — it is the price you pay for zero commitment and infinite flexibility. For any workload running more than ~600 hours per month (about 83% uptime), some form of commitment is almost certainly cheaper.

Savings Plans: the modern default

Savings Plans are a dollar-per-hour spend commitment that AWS applies as a discount against on-demand charges. You commit to spending, say, $5.00/hour on compute, and AWS gives you 40–66% off on-demand rates on all compute resources up to that hourly spend. Above the commitment, charges revert to on-demand.

Three types:

Type Applies to Discount vs on-demand Flexibility
Compute Savings Plans EC2, Lambda, Fargate, ECS Up to 66% Any instance family, size, region, OS
EC2 Instance Savings Plans EC2 only (specific family + region) Up to 72% Any size and OS within the family
SageMaker Savings Plans SageMaker only Up to 64% Any instance type, region

Compute Savings Plans are the right default for most organizations. They apply across instance families and regions, which means a commitment made against your current m5 fleet still applies if you migrate to m7i or move a workload to a different region. The 6% lower discount compared to EC2 Instance Savings Plans is worth the flexibility in nearly every case.

EC2 Instance Savings Plans make sense when you are very confident a specific instance family in a specific region is stable for 1–3 years. A production database fleet on r6i in us-east-1 that has been running the same way for 18 months is a candidate.

Commitment terms are 1-year or 3-year. Payment options are all-upfront (maximum discount), partial-upfront, or no-upfront. The discount difference between all-upfront and no-upfront is typically 5–10 percentage points — a meaningful spread if you have the capital.

How much to commit: AWS Savings Plans recommendations in Cost Explorer calculate the optimal commitment based on your trailing 7, 30, or 60 days of usage. The recommendation targets a coverage percentage you specify. Start at 70% coverage of your on-demand baseline — this covers the stable floor of your usage without overcommitting on variable peaks. Adjust after observing the savings for a month.

Cost Explorer → Savings Plans → Recommendations
  Savings Plans type: Compute Savings Plans
  Term: 1 Year
  Payment: No upfront
  Look-back period: 30 days
  → Review estimated monthly savings and coverage percentage

Reserved Instances: where they still make sense

Reserved Instances commit to specific instance attributes (type, region, OS, tenancy) in exchange for up to 72% off on-demand. Standard RIs are inflexible — you cannot change the instance type after purchase. Convertible RIs allow exchanges but provide lower discounts (~54%).

RIs remain the right tool in specific cases:

  • RDS: Savings Plans do not cover RDS. RDS Reserved Instances provide up to 69% discount and are the only commitment discount available for database instances.
  • ElastiCache, Redshift, OpenSearch: similar situation — Savings Plans do not apply; service-specific RIs do.
  • Exact instance type stability: if you are certain a workload will run on m5.4xlarge in us-east-1 for three years, a Standard RI provides the maximum discount.

For EC2 and compute workloads where Savings Plans apply, RIs are generally not worth the management overhead.

Spot Instances

Spot is spare EC2 capacity at 70–90% discount, with the trade-off that AWS can reclaim it with a 2-minute warning. The right tool for:

  • Stateless, fault-tolerant workloads
  • Batch processing (ML training, data pipelines, CI/CD runners)
  • Dev/test environments
  • EKS worker nodes managed by Karpenter (handles interruption automatically)

The mistake is treating Spot as risky and avoiding it for workloads that are actually tolerant of interruption. A CI pipeline that retries jobs has nothing to fear from a Spot interruption. An EKS cluster with proper PodDisruptionBudgets handles Spot node termination routinely. The organizations that save 40–60% on compute are the ones that systematically identify which workloads can tolerate interruption and run those on Spot.

Commitment strategy in practice

A practical three-tier commitment strategy:

Baseline (stable, always-on workloads)
  └── Compute Savings Plans: 70% of baseline on-demand spend
       └── RDS/ElastiCache: service-specific Reserved Instances

Variable layer (scales with demand)
  └── On-demand: covered by Savings Plans above the baseline

Burst/batch layer
  └── Spot: 70–90% discount for interruptible workloads

Review and adjust commitments quarterly. As you rightsize instances or retire workloads, your on-demand baseline changes. Overcommitting Savings Plans means paying for hours you are not using — the excess does not roll forward.


Tagging: The Foundation of Cost Allocation

Without tags, your Cost Explorer shows “EC2: $142,000/month” with no visibility into which team, service, or environment drives that number. With consistent tagging, you see “team:payments production: $23,400, team:payments staging: $4,100, team:data-platform production: $31,200.” That is the difference between a cost review and a cost conversation.

Mandatory tag taxonomy

Define a small set of required tags that every resource must carry:

Tag key Values Purpose
team payments, platform, data, security Owner for chargeback
environment production, staging, development Cost split by env
service checkout-api, fraud-engine, kafka Service-level cost
cost-center Finance GL codes Finance chargeback

Keep the required set small. Four mandatory tags enforced consistently beats twenty tags with 60% compliance. Add optional tags (project, ticket, etc.) but do not make them required — enforcement complexity scales with requirement count.

Enforcing tags with Tag Policies and SCPs

AWS Tag Policies enforce tag keys and value patterns across an organization:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
  "tags": {
    "team": {
      "tag_value": {
        "@@assign": ["payments", "platform", "data", "security", "infrastructure"]
      },
      "enforced_for": {
        "@@assign": [
          "ec2:instance",
          "rds:db",
          "rds:cluster",
          "elasticache:cluster",
          "s3:bucket",
          "lambda:function"
        ]
      }
    },
    "environment": {
      "tag_value": {
        "@@assign": ["production", "staging", "development", "sandbox"]
      }
    }
  }
}

Attach this policy at the organization root or at the OU level. Resources that violate the policy are flagged in the Tag Policy console — not blocked by default (you can enable enforcement mode, but it breaks existing Terraform/CloudFormation that does not include the tags, so roll it out incrementally).

For new infrastructure via Terraform, enforce tags at the provider level:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
provider "aws" {
  region = "us-east-1"
  default_tags {
    tags = {
      team        = var.team
      environment = var.environment
      service     = var.service
      managed_by  = "terraform"
    }
  }
}

default_tags applies to every resource created by the provider without requiring explicit tags blocks on each resource. This catches the resources engineers forget to tag.

Finding untagged resources

1
2
3
4
5
6
7
8
9
# List EC2 instances missing the "team" tag
aws ec2 describe-instances \
  --filters "Name=instance-state-name,Values=running" \
  --query 'Reservations[].Instances[?!not_null(Tags[?Key==`team`].Value | [0])].[InstanceId, InstanceType]' \
  --output table

# AWS Resource Groups Tag Editor (console)
# Resource Groups → Tag Editor → All resource types → Search
# Filter: tag key "team" does not exist

The Tag Editor in the AWS console provides a bulk interface to add tags to existing untagged resources.

Activate tags for Cost Allocation

Creating tags on resources does not automatically make them available in Cost Explorer. Activate them:

Billing → Cost allocation tags → User-defined → Activate
  Select: team, environment, service, cost-center

Activation takes up to 24 hours to propagate. Historical data before activation is not retroactively tagged.


Rightsizing

Rightsizing is matching instance size to actual workload requirements. The typical finding: 20–40% of EC2 instances in an organization are significantly overprovisioned — running at 5–15% average CPU utilization on instances sized for peak load that rarely arrives.

AWS Compute Optimizer

Compute Optimizer analyzes CloudWatch metrics (CPU, memory if the CloudWatch agent is installed, network, disk) and recommends instance changes:

Compute Optimizer → EC2 instances
  Filter: Finding = Over-provisioned
  Sort by: Estimated monthly savings descending

The output shows:

  • Current instance type and cost
  • Recommended instance type and estimated cost
  • CPU and memory utilization percentiles (P50, P99)
  • Estimated monthly savings

Prioritize recommendations by savings, not count. One r5.16xlarge recommendation may save more than 50 t3.small recommendations combined.

What Compute Optimizer does not tell you

Compute Optimizer looks at CPU and (optionally) memory. It does not measure:

  • Network throughput: an instance at 5% CPU may be at 90% network bandwidth
  • IOPS: a database instance may be CPU-idle but disk-bound
  • Application-level performance: reducing instance size may increase response times even if CPU headroom appears to exist

For EC2 running databases or network-intensive applications, validate recommendations with application-level metrics (query latency, error rates) on a non-production instance before applying to production.

Memory metrics require the CloudWatch agent

By default, CloudWatch does not collect memory utilization from EC2 instances — the hypervisor cannot see inside the guest. Without memory metrics, Compute Optimizer cannot assess memory utilization and marks recommendations as having “insufficient data.”

Install the CloudWatch agent via SSM:

1
2
3
4
5
aws ssm send-command \
  --document-name "AWS-ConfigureAWSPackage" \
  --targets "Key=tag:environment,Values=production" \
  --parameters '{"action":["Install"],"name":["AmazonCloudWatchAgent"]}' \
  --output text

Configure it to collect memory metrics:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "metrics": {
    "namespace": "CWAgent",
    "metrics_collected": {
      "mem": {
        "measurement": ["mem_used_percent"],
        "metrics_collection_interval": 60
      }
    }
  }
}

gp2 to gp3 migration

EBS gp2 volumes are almost always more expensive than gp3 for the same size. gp3 provides 3,000 IOPS and 125 MB/s baseline throughput at 20% lower cost per GB, with IOPS and throughput independently configurable up to 16,000 IOPS and 1,000 MB/s. gp2 scales IOPS with volume size (3 IOPS/GB) and costs more.

Migrate all gp2 volumes to gp3:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Find all gp2 volumes
aws ec2 describe-volumes \
  --filters "Name=volume-type,Values=gp2" \
  --query 'Volumes[*].[VolumeId,Size,Iops]' \
  --output table

# Modify a single volume (online, no downtime)
aws ec2 modify-volume \
  --volume-id vol-0abc123 \
  --volume-type gp3 \
  --iops 3000 \
  --throughput 125

# Modify all gp2 volumes in a region (run in batches; there is a concurrent modification limit)
aws ec2 describe-volumes \
  --filters "Name=volume-type,Values=gp2" \
  --query 'Volumes[*].VolumeId' \
  --output text | tr '\t' '\n' | while read vid; do
    aws ec2 modify-volume --volume-id "$vid" --volume-type gp3 \
      --iops 3000 --throughput 125 --no-cli-pager
    echo "Modified $vid"
    sleep 1  # avoid API throttling
  done

For Terraform-managed volumes, update the type:

1
2
3
4
5
6
resource "aws_ebs_volume" "data" {
  type       = "gp3"   # was "gp2"
  iops       = 3000
  throughput = 125
  size       = 100
}

Hunting Idle and Zombie Resources

Industry estimates put idle resource waste at 30–40% of total cloud spend. In practice the distribution is skewed — most of the waste is concentrated in a small number of expensive resources that nobody is monitoring.

EC2: stopped instances

Stopped EC2 instances do not incur instance-hour charges, but their attached EBS volumes continue billing. A r5.4xlarge stopped “temporarily” six months ago still pays for its 500 GB gp3 root volume and data volume at $40+/month.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Find stopped instances and their attached volumes
aws ec2 describe-instances \
  --filters "Name=instance-state-name,Values=stopped" \
  --query 'Reservations[].Instances[].[
    InstanceId,
    InstanceType,
    Tags[?Key==`Name`].Value|[0],
    LaunchTime,
    BlockDeviceMappings[].Ebs.VolumeId
  ]' \
  --output json | jq -r '.[] | @tsv'

For each stopped instance, determine: is this a deliberate pause or forgotten? Check CloudTrail for when it was stopped and who stopped it. If it has been stopped for more than 30 days with no recent activity, raise it with the owner.

Unattached EBS volumes

EBS volumes that exist but are not attached to any instance are paying for nothing:

1
2
3
4
aws ec2 describe-volumes \
  --filters "Name=status,Values=available" \
  --query 'Volumes[*].[VolumeId,VolumeType,Size,CreateTime,Tags[?Key==`Name`].Value|[0]]' \
  --output table

Create a snapshot before deleting, in case the data is needed:

1
2
3
aws ec2 create-snapshot --volume-id vol-0abc123 --description "pre-delete snapshot"
# After snapshot completes:
aws ec2 delete-volume --volume-id vol-0abc123

Unused Elastic IPs

Every Elastic IP not associated with a running instance costs $0.005/hour (~$3.65/month). Individually small, collectively significant in large organizations:

1
2
3
4
5
6
aws ec2 describe-addresses \
  --query 'Addresses[?AssociationId==null].[AllocationId,PublicIp,Tags[?Key==`Name`].Value|[0]]' \
  --output table

# Release unneeded EIPs
aws ec2 release-address --allocation-id eipalloc-0abc123

Idle load balancers

ALBs and NLBs without target groups, or with target groups containing no healthy targets, still cost $16–22/month each:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Find ALBs with no targets
aws elbv2 describe-load-balancers \
  --query 'LoadBalancers[*].[LoadBalancerArn,DNSName]' \
  --output text | while read arn dns; do
    TARGET_COUNT=$(aws elbv2 describe-target-groups --load-balancer-arn "$arn" \
      --query 'TargetGroups[*].TargetGroupArn' --output text | wc -w)
    if [ "$TARGET_COUNT" -eq 0 ]; then
      echo "No target groups: $dns ($arn)"
    fi
  done

Old snapshots

EBS snapshots accumulate silently. A backup policy that creates daily snapshots and never deletes them will have years of snapshots for decommissioned instances. At $0.05/GB-month, 10 TB of forgotten snapshots costs $500/month.

1
2
3
4
5
6
# List snapshots older than 180 days owned by your account
CUTOFF=$(date -d '180 days ago' +%Y-%m-%d)
aws ec2 describe-snapshots \
  --owner-ids self \
  --query "Snapshots[?StartTime<='${CUTOFF}'].[SnapshotId,StartTime,VolumeSize,Description]" \
  --output table

AWS DLM (Data Lifecycle Manager) automates snapshot retention policy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
aws dlm create-lifecycle-policy \
  --description "30-day retention for production snapshots" \
  --state ENABLED \
  --execution-role-arn arn:aws:iam::123:role/AWSDataLifecycleManagerDefaultRole \
  --policy-details '{
    "PolicyType": "EBS_SNAPSHOT_MANAGEMENT",
    "ResourceTypes": ["VOLUME"],
    "TargetTags": [{"Key": "environment", "Value": "production"}],
    "Schedules": [{
      "Name": "Daily",
      "CreateRule": {"Interval": 24, "IntervalUnit": "HOURS", "Times": ["00:00"]},
      "RetainRule": {"Count": 30},
      "CopyTags": true
    }]
  }'

RDS idle instances

Development and staging RDS instances often run 24/7 even though they are only used during business hours. Stopping them nights and weekends saves 65% of the cost (RDS stopped instances still charge for storage):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Stop an RDS instance (max 7 days; it auto-starts after that)
aws rds stop-db-instance --db-instance-identifier dev-postgres

# Automate via EventBridge Scheduler
aws scheduler create-schedule \
  --name stop-dev-rds-evenings \
  --schedule-expression "cron(0 20 ? * MON-FRI *)" \
  --flexible-time-window '{"Mode":"OFF"}' \
  --target '{
    "Arn": "arn:aws:scheduler:::aws-sdk:rds:stopDBInstance",
    "RoleArn": "arn:aws:iam::123:role/EventBridgeSchedulerRole",
    "Input": "{\"DbInstanceIdentifier\": \"dev-postgres\"}"
  }'

aws scheduler create-schedule \
  --name start-dev-rds-mornings \
  --schedule-expression "cron(0 8 ? * MON-FRI *)" \
  --flexible-time-window '{"Mode":"OFF"}' \
  --target '{
    "Arn": "arn:aws:scheduler:::aws-sdk:rds:startDBInstance",
    "RoleArn": "arn:aws:iam::123:role/EventBridgeSchedulerRole",
    "Input": "{\"DbInstanceIdentifier\": \"dev-postgres\"}"
  }'

Data Transfer: The Hidden Bill

Data transfer charges are opaque, widely misunderstood, and responsible for unexpectedly large line items. The relevant pricing:

Transfer type Cost
Inbound from internet Free
Outbound to internet $0.09/GB (first 10 TB/month)
Cross-AZ within region $0.01/GB each direction
Cross-region $0.02/GB (varies by region pair)
NAT Gateway processing $0.045/GB
Same-AZ Free
VPC endpoints (Gateway: S3, DynamoDB) Free
VPC Interface Endpoints $0.01/GB

NAT Gateway: the most common surprise

A NAT Gateway processing 10 TB/month costs $450/month in data processing charges alone (plus $32/month per gateway in hourly fees). Common patterns that drive NAT Gateway costs unnecessarily:

S3 and DynamoDB traffic through NAT: All traffic to S3 and DynamoDB should route through free Gateway endpoints. The VPC endpoint takes 5 minutes to configure and eliminates all data processing charges for that traffic:

1
2
3
4
5
6
7
8
9
# Check current NAT Gateway data processing in Cost Explorer
# Filter: Usage Type contains "NatGateway-Bytes"

# Create Gateway endpoints for S3 and DynamoDB
aws ec2 create-vpc-endpoint \
  --vpc-id vpc-0abc123 \
  --service-name com.amazonaws.us-east-1.s3 \
  --vpc-endpoint-type Gateway \
  --route-table-ids rtb-private1 rtb-private2 rtb-private3

Cross-AZ data transfer through NAT: if an EC2 instance in us-east-1b makes requests that route to a NAT Gateway in us-east-1a, each byte traverses the cross-AZ link twice (once to NAT, once back), paying $0.02/GB in cross-AZ fees plus $0.045/GB in NAT processing fees. Place NAT Gateways in each AZ and update route tables so each private subnet routes to the NAT Gateway in its own AZ.

ECR image pulls through NAT: Docker image pulls from ECR through NAT Gateway can add hundreds of dollars to the bill for clusters that frequently pull large images. Create VPC Interface Endpoints for ECR:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# ECR needs two endpoints: api and dkr
aws ec2 create-vpc-endpoint \
  --vpc-id vpc-0abc123 \
  --service-name com.amazonaws.us-east-1.ecr.api \
  --vpc-endpoint-type Interface \
  --subnet-ids subnet-private1 subnet-private2 \
  --security-group-ids sg-endpoints \
  --private-dns-enabled

aws ec2 create-vpc-endpoint \
  --vpc-id vpc-0abc123 \
  --service-name com.amazonaws.us-east-1.ecr.dkr \
  --vpc-endpoint-type Interface \
  --subnet-ids subnet-private1 subnet-private2 \
  --security-group-ids sg-endpoints \
  --private-dns-enabled

Finding cross-AZ traffic

Cross-AZ charges ($0.01/GB each direction) accumulate invisibly. The resource IDs causing cross-AZ traffic are not visible in Cost Explorer; you need CUR or VPC Flow Logs analysis.

A rough indicator: if your EKS pods are distributed across AZs but your RDS read replicas, ElastiCache nodes, or other backend services are concentrated in fewer AZs, every cross-AZ request pays the toll. Use availability zone affinity for pods to route requests to backends in the same AZ where possible.


S3 Cost Optimization

S3 costs have three components: storage ($0.023/GB/month for S3 Standard), requests ($0.0004 per 1,000 GET requests), and data retrieval (free for Standard, varies for other tiers). The lever is storage class.

S3 Intelligent-Tiering

S3 Intelligent-Tiering automatically moves objects to cheaper storage classes based on access patterns. Objects not accessed for 30 days move to Infrequent Access; not accessed for 90 days move to Glacier Instant Retrieval; not accessed for 180 days move to Glacier Flexible Retrieval.

Tier Cost/GB/month Retrieval
Frequent Access $0.023 Immediate
Infrequent Access $0.0125 Immediate
Glacier Instant Retrieval $0.004 Immediate
Glacier Flexible Retrieval $0.0036 Minutes to hours

Intelligent-Tiering costs $0.0025 per 1,000 objects monitored per month (for objects > 128 KB). For buckets with large objects accessed infrequently, this pays for itself within the first month.

Apply Intelligent-Tiering to appropriate buckets:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
aws s3api put-bucket-lifecycle-configuration \
  --bucket my-data-bucket \
  --lifecycle-configuration '{
    "Rules": [{
      "ID": "intelligent-tiering",
      "Status": "Enabled",
      "Filter": {"Prefix": ""},
      "Transitions": [{
        "Days": 0,
        "StorageClass": "INTELLIGENT_TIERING"
      }]
    }]
  }'

For known-cold data (compliance archives, old log files), skip Intelligent-Tiering and go directly to Glacier:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
aws s3api put-bucket-lifecycle-configuration \
  --bucket compliance-archive \
  --lifecycle-configuration '{
    "Rules": [{
      "ID": "archive-after-90-days",
      "Status": "Enabled",
      "Filter": {"Prefix": ""},
      "Transitions": [
        {"Days": 90, "StorageClass": "GLACIER"}
      ],
      "Expiration": {"Days": 2555}
    }]
  }'

Expire old log data

Application and access logs in S3 are rarely accessed after 90 days but accumulate indefinitely without a lifecycle policy. Set expiration policies on all log buckets:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
aws s3api put-bucket-lifecycle-configuration \
  --bucket application-logs \
  --lifecycle-configuration '{
    "Rules": [{
      "ID": "expire-old-logs",
      "Status": "Enabled",
      "Filter": {"Prefix": ""},
      "Expiration": {"Days": 90}
    }]
  }'

Budgets and Alerts

The most common cost surprise is discovering a large bill at the end of the month. Budgets alert before that happens.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# Alert when monthly EC2 spend exceeds $10,000
aws budgets create-budget \
  --account-id 123456789012 \
  --budget '{
    "BudgetName": "EC2-Monthly-10k",
    "BudgetLimit": {"Amount": "10000", "Unit": "USD"},
    "TimeUnit": "MONTHLY",
    "BudgetType": "COST",
    "CostFilters": {"Service": ["Amazon Elastic Compute Cloud - Compute"]}
  }' \
  --notifications-with-subscribers '[{
    "Notification": {
      "NotificationType": "ACTUAL",
      "ComparisonOperator": "GREATER_THAN",
      "Threshold": 80,
      "ThresholdType": "PERCENTAGE"
    },
    "Subscribers": [{"SubscriptionType": "EMAIL", "Address": "platform@acme.com"}]
  }, {
    "Notification": {
      "NotificationType": "FORECASTED",
      "ComparisonOperator": "GREATER_THAN",
      "Threshold": 100,
      "ThresholdType": "PERCENTAGE"
    },
    "Subscribers": [{"SubscriptionType": "EMAIL", "Address": "platform@acme.com"}]
  }]'

Set both actual (you have spent 80% of budget) and forecasted (you are projected to exceed budget) alerts. Forecasted alerts give you time to act before the overage occurs.

Use anomaly detection for sudden spikes that stay within monthly budget but represent unexpected behavior:

Cost Explorer → Cost Anomaly Detection → Create monitor
  Monitor type: AWS services
  Alert threshold: $500 or 20% above expected (whichever is higher)
  Notification: platform team email

The Organizational Work

Technical optimization is the easier half. The harder half is building the processes that prevent costs from drifting back up.

Showback before chargeback

Showback means showing teams their cost data without billing them for it. Chargeback means actually transferring the cost to their budget. Start with showback — get teams looking at their costs and building intuition for what they are responsible for, before the political complexity of internal billing.

A weekly cost email per team (automated from Cost Explorer data via Lambda) is more effective than a quarterly review: teams see costs when they can still act on them, the feedback loop is short, and small surprises get addressed before they become large ones.

The “Cost is an engineering metric” framing

Cost alerts that go to a finance team and generate tickets for engineering are slower and more adversarial than cost data surfaced directly to the engineers making infrastructure decisions. The same engineer who launches a r5.16xlarge when a r5.4xlarge would suffice will make a different decision when they see the cost attribution weekly and know it appears in a dashboard their manager also reads.

Cost reviews with architecture context

The most useful cost optimization sessions combine cost data with architecture knowledge:

  • “This RDS instance costs $4,200/month at 8% CPU. What is it actually doing?”
  • “This NAT Gateway processed 50 TB last month. What is generating that traffic?”
  • “These 12 EC2 instances have not had a connection in 45 days. Do they belong to the project that was cancelled?”

Without the architecture context, cost data is a spreadsheet. With it, cost data is a diagnostic tool.


Prioritizing the Work

Not all cost optimizations are worth the time to implement. A rough priority framework:

Action Typical savings Effort Do first?
Savings Plans purchase 40–66% of stable compute spend 30 minutes Yes
gp2 → gp3 migration 20% of EBS spend 1–2 hours Yes
S3 lifecycle policies 50–80% of storage for cold data 2–4 hours Yes
Idle resource cleanup Varies; often $500–5,000/month 1 day Yes
NAT Gateway VPC endpoints Varies; often $200–2,000/month 2–4 hours Yes
RDS dev/staging scheduling 65% of dev RDS cost 2–4 hours Yes
Rightsizing (EC2) 20–40% of on-demand compute Days to weeks After above
Spot migration (EKS) 60–80% of node group cost Days After rightsizing
Cross-AZ traffic reduction Varies Weeks Advanced

The first six items on this list are mechanical and low-risk. Do them before optimizing architecture. The savings from these actions are permanent — once a lifecycle policy is applied or a Savings Plan is purchased, it runs without ongoing attention.

Rightsizing and Spot migration require more care but also return more. After the mechanical items, these are where the sustained savings come from.

The FinOps literature is full of frameworks, maturity models, and capability assessments. None of that matters until you have done the mechanical work and built the tagging foundation. Start there.

Comments