The Hidden Cost of Idle EC2 Instances
Every AWS account accumulates zombie instances over time. Development environments left running after a sprint ends. Load test infrastructure that nobody decommissioned. Staging clusters that outlived the feature branch they served. According to Gartner's 2025 Cloud Waste Report, organizations waste an average of 32% of their cloud spend on idle or underutilized resources, with EC2 instances representing the single largest category of waste.
The math is brutal. A single idle m5.xlarge instance running 24/7 in us-east-1 costs approximately $140/month, or $1,680/year. Scale that across a mid-size engineering organization running 50-100 such instances forgotten across multiple accounts, and you are looking at $84,000 to $168,000 annually in pure waste. For startups burning runway, this is the difference between an extra engineer on the team or two more months of operating capital.
The most expensive instance in your fleet is not the largest one running production workloads. It is the medium-sized one running nothing at all, because nobody is watching it.
This guide provides a systematic approach to detecting, classifying, and eliminating idle EC2 instances. We cover the metrics that matter, the CLI commands to surface waste, automation patterns for continuous detection, and the decision framework for what to do once you find zombie VMs.
Defining "Idle": CloudWatch Metric Thresholds
Before you can detect idle instances, you need a precise definition. "Idle" is not binary; it exists on a spectrum. An instance might be genuinely idle (doing zero useful work), underutilized (doing some work but dramatically over-provisioned), or bursty (idle 95% of the time but critical during that 5%).
Here are the CloudWatch metric thresholds that reliably identify idle instances when observed over a sustained period (minimum 7 days, ideally 14):
| Metric | Idle Threshold | Namespace | Statistic |
|---|---|---|---|
| CPUUtilization | < 5% average | AWS/EC2 | Average |
| NetworkIn | < 1 MB/day | AWS/EC2 | Sum |
| NetworkOut | < 1 MB/day | AWS/EC2 | Sum |
| DiskReadOps | < 100 ops/day | AWS/EC2 | Sum |
| DiskWriteOps | < 100 ops/day | AWS/EC2 | Sum |
| NetworkPacketsIn | < 1000/day | AWS/EC2 | Sum |
| EBSReadBytes | < 10 MB/day | AWS/EBS | Sum |
| EBSWriteBytes | < 10 MB/day | AWS/EBS | Sum |
The critical nuance is that you must evaluate all of these metrics together. A bastion host might show zero CPU but steady network traffic. A batch processing node might show zero network but periodic CPU spikes. Only instances that fail across all metrics simultaneously qualify as truly idle.
Do not rely on CPU alone. An instance running a memory-cached database will show near-zero CPU while serving production traffic. Always cross-reference CPU with network and disk metrics.
For the CW agent-level metrics (memory utilization, process count), you need the CloudWatch agent installed. If it is not present, you lose visibility into memory-bound workloads. Consider this a gap in your detection coverage rather than evidence of idleness.
Sustained Period Requirements
A single snapshot is meaningless. Instances that appear idle at 3 AM on a Sunday might be critical at 9 AM on Monday. Your detection window matters:
- 7 days minimum: Catches weekly patterns (batch jobs, scheduled tasks)
- 14 days recommended: Catches biweekly deploy cycles and sprint boundaries
- 30 days for production: Guards against monthly processes (billing cycles, reporting jobs)
Querying Idle Instances with AWS CLI
Let us build a practical detection workflow using the AWS CLI. This approach works across all account types and requires no additional tooling.
Step 1: List All Running Instances
Start by enumerating every running instance in the target region:
aws ec2 describe-instances \
--filters "Name=instance-state-name,Values=running" \
--query "Reservations[*].Instances[*].{
InstanceId:InstanceId,
Type:InstanceType,
LaunchTime:LaunchTime,
Name:Tags[?Key=='Name']|[0].Value,
State:State.Name
}" \
--output table \
--region us-east-1
For multi-region scanning, wrap this in a loop:
for region in $(aws ec2 describe-regions --query "Regions[*].RegionName" --output text); do
echo "=== Region: $region ==="
aws ec2 describe-instances \
--filters "Name=instance-state-name,Values=running" \
--query "Reservations[*].Instances[*].[InstanceId,InstanceType,LaunchTime]" \
--output table \
--region "$region"
done
Step 2: Pull CPU Utilization for Each Instance
For a specific instance, query 14 days of CPU data with 1-hour granularity:
aws cloudwatch get-metric-statistics \
--namespace AWS/EC2 \
--metric-name CPUUtilization \
--dimensions Name=InstanceId,Value=i-0abc123def456789 \
--start-time $(date -u -d '14 days ago' +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 3600 \
--statistics Average Maximum \
--region us-east-1
Look for instances where the maximum CPU over 14 days never exceeds 5%. This is your strongest idle signal.
Step 3: Check Network Activity
aws cloudwatch get-metric-statistics \
--namespace AWS/EC2 \
--metric-name NetworkIn \
--dimensions Name=InstanceId,Value=i-0abc123def456789 \
--start-time $(date -u -d '14 days ago' +%Y-%m-%dT%H:%M:%S) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%S) \
--period 86400 \
--statistics Sum \
--region us-east-1
An instance receiving less than 1 MB of network traffic per day (1,048,576 bytes in the Sum statistic with a daily period) is almost certainly not serving any meaningful workload.
Step 4: Batch Detection Script
Here is a comprehensive script that checks all metrics for all running instances in a region:
#!/bin/bash
# detect-idle-ec2.sh - Identify idle EC2 instances across all metrics
# Usage: ./detect-idle-ec2.sh us-east-1 14
REGION="${1:-us-east-1}"
DAYS="${2:-14}"
CPU_THRESHOLD=5
NETWORK_THRESHOLD=1048576 # 1 MB in bytes
START_TIME=$(date -u -d "${DAYS} days ago" +%Y-%m-%dT%H:%M:%S)
END_TIME=$(date -u +%Y-%m-%dT%H:%M:%S)
echo "Scanning region: $REGION (last $DAYS days)"
echo "CPU threshold: <${CPU_THRESHOLD}% | Network threshold: <1 MB/day"
echo "=========================================="
INSTANCE_IDS=$(aws ec2 describe-instances \
--filters "Name=instance-state-name,Values=running" \
--query "Reservations[*].Instances[*].InstanceId" \
--output text \
--region "$REGION")
for INSTANCE_ID in $INSTANCE_IDS; do
# Get instance metadata
INSTANCE_TYPE=$(aws ec2 describe-instances \
--instance-ids "$INSTANCE_ID" \
--query "Reservations[0].Instances[0].InstanceType" \
--output text --region "$REGION")
INSTANCE_NAME=$(aws ec2 describe-instances \
--instance-ids "$INSTANCE_ID" \
--query "Reservations[0].Instances[0].Tags[?Key=='Name'].Value" \
--output text --region "$REGION")
# Check CPU (max over period)
MAX_CPU=$(aws cloudwatch get-metric-statistics \
--namespace AWS/EC2 \
--metric-name CPUUtilization \
--dimensions Name=InstanceId,Value="$INSTANCE_ID" \
--start-time "$START_TIME" --end-time "$END_TIME" \
--period $((DAYS * 86400)) \
--statistics Maximum \
--query "Datapoints[0].Maximum" \
--output text --region "$REGION")
# Check NetworkIn (total over period)
TOTAL_NET_IN=$(aws cloudwatch get-metric-statistics \
--namespace AWS/EC2 \
--metric-name NetworkIn \
--dimensions Name=InstanceId,Value="$INSTANCE_ID" \
--start-time "$START_TIME" --end-time "$END_TIME" \
--period $((DAYS * 86400)) \
--statistics Sum \
--query "Datapoints[0].Sum" \
--output text --region "$REGION")
# Evaluate idle status
if (( $(echo "$MAX_CPU < $CPU_THRESHOLD" | bc -l 2>/dev/null) )) && \
(( $(echo "$TOTAL_NET_IN < $NETWORK_THRESHOLD" | bc -l 2>/dev/null) )); then
echo "[IDLE] $INSTANCE_ID ($INSTANCE_TYPE) - $INSTANCE_NAME"
echo " CPU max: ${MAX_CPU}% | Net in: ${TOTAL_NET_IN} bytes"
fi
done
Step 5: Identify Stopped Instances Still Costing Money
Stopped instances do not incur compute charges, but their attached EBS volumes continue billing:
aws ec2 describe-instances \
--filters "Name=instance-state-name,Values=stopped" \
--query "Reservations[*].Instances[*].{
InstanceId:InstanceId,
Type:InstanceType,
StopTime:StateTransitionReason,
Volumes:BlockDeviceMappings[*].Ebs.VolumeId
}" \
--output json \
--region us-east-1
Then check how long they have been stopped and the cost of their attached storage:
aws ec2 describe-volumes \
--volume-ids vol-0abc123def456789 \
--query "Volumes[*].{VolumeId:VolumeId,Size:Size,Type:VolumeType,State:State}" \
--output table
A 500 GB gp3 volume attached to a stopped instance costs approximately $40/month regardless of whether the instance is running. Multiply this across forgotten stopped instances and the waste adds up quickly.
See how much you're wasting
Get a free 7-day cloud audit. No credit card, no agents, read-only access.
The Decision Matrix: Stop vs. Terminate vs. Rightsize
Once you have identified idle or underutilized instances, you need a framework for action. The wrong decision can cause outages (terminating something that was actually needed) or continued waste (stopping something that should be deleted entirely).
| Condition | Action | Rationale |
|---|---|---|
| Idle > 30 days, no owner identified, no DNS/LB references | Terminate | No evidence of purpose; snapshot EBS first |
| Idle > 14 days, owner identified, confirms not needed | Terminate | Owner-approved decommission |
| Idle > 7 days, owner identified, might be needed later | Stop | Preserves state; eliminates compute cost |
| CPU < 5% average but periodic spikes to 20-40% | Rightsize | Workload exists but instance is over-provisioned |
| CPU < 5% average, steady low network (monitoring agent) | Rightsize to t3.micro/t4g.nano | Lightweight daemon; needs smallest possible instance |
| Part of ASG, consistently idle | Reduce ASG min/desired | Let the scaling policy handle it |
| Reserved Instance or Savings Plan committed | Rightsize or repurpose | You pay regardless; move workload to use commitment |
| Has Elastic IP attached | Terminate + release EIP | Idle instance + unused EIP = double waste |
| Spot instance, idle | Terminate immediately | No commitment; zero reason to keep |
Before terminating any instance, create an AMI snapshot and note the instance configuration in your CMDB. The cost of a snapshot ($0.05/GB-month) is trivial compared to the cost of recreating an environment from scratch if you were wrong.
Pre-Termination Checklist
Run through this checklist before terminating any instance flagged as idle:
- DNS check: Does any Route 53 record or external DNS point to this instance's IP?
- Load balancer check: Is this instance registered in any ALB/NLB target group?
- Security group references: Do other security groups reference this instance's SG as a source?
- IAM role usage: Is the instance's IAM role referenced by other services (Lambda, ECS)?
- Elastic IP: Does it have an EIP that other services depend on?
- Data check: Are there local instance store volumes with unreplicated data?
# Check if instance is in any target group
aws elbv2 describe-target-health \
--query "TargetHealthDescriptions[?Target.Id=='i-0abc123def456789']" \
--output json
# Check DNS records pointing to the instance
INSTANCE_IP=$(aws ec2 describe-instances \
--instance-ids i-0abc123def456789 \
--query "Reservations[0].Instances[0].PublicIpAddress" \
--output text)
aws route53 list-resource-record-sets \
--hosted-zone-id Z1234567890ABC \
--query "ResourceRecordSets[?ResourceRecords[?Value=='$INSTANCE_IP']]"
Automating Detection with Lambda and EventBridge
Manual detection does not scale. Once you have validated your thresholds, automate the process with a Lambda function triggered on a schedule via EventBridge.
Architecture Overview
The automation pattern is straightforward:
- EventBridge Rule: Triggers Lambda on a cron schedule (daily at 6 AM UTC)
- Lambda Function: Queries CloudWatch metrics for all running instances
- Classification: Applies threshold logic to categorize instances
- Notification: Sends findings to SNS (email), Slack webhook, or writes to DynamoDB
- Optional Action: Auto-stops instances idle beyond a configurable threshold
Lambda Function Implementation
import boto3
import json
from datetime import datetime, timedelta
ec2 = boto3.client('ec2')
cloudwatch = boto3.client('cloudwatch')
sns = boto3.client('sns')
# Configuration
CPU_THRESHOLD = 5.0 # percent
NETWORK_THRESHOLD = 1048576 # 1 MB in bytes (daily)
LOOKBACK_DAYS = 14
SNS_TOPIC_ARN = 'arn:aws:sns:us-east-1:123456789012:idle-instance-alerts'
def lambda_handler(event, context):
idle_instances = []
# Get all running instances
response = ec2.describe_instances(
Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]
)
end_time = datetime.utcnow()
start_time = end_time - timedelta(days=LOOKBACK_DAYS)
for reservation in response['Reservations']:
for instance in reservation['Instances']:
instance_id = instance['InstanceId']
instance_type = instance['InstanceType']
# Get instance name from tags
name = next(
(tag['Value'] for tag in instance.get('Tags', [])
if tag['Key'] == 'Name'),
'unnamed'
)
# Check CPU utilization (maximum over period)
cpu_stats = cloudwatch.get_metric_statistics(
Namespace='AWS/EC2',
MetricName='CPUUtilization',
Dimensions=[{'Name': 'InstanceId', 'Value': instance_id}],
StartTime=start_time,
EndTime=end_time,
Period=LOOKBACK_DAYS * 86400,
Statistics=['Maximum', 'Average']
)
# Check network in (sum over period)
net_stats = cloudwatch.get_metric_statistics(
Namespace='AWS/EC2',
MetricName='NetworkIn',
Dimensions=[{'Name': 'InstanceId', 'Value': instance_id}],
StartTime=start_time,
EndTime=end_time,
Period=LOOKBACK_DAYS * 86400,
Statistics=['Sum']
)
# Extract values (handle missing data)
max_cpu = (cpu_stats['Datapoints'][0]['Maximum']
if cpu_stats['Datapoints'] else 0)
avg_cpu = (cpu_stats['Datapoints'][0]['Average']
if cpu_stats['Datapoints'] else 0)
total_net_in = (net_stats['Datapoints'][0]['Sum']
if net_stats['Datapoints'] else 0)
daily_net_in = total_net_in / LOOKBACK_DAYS
# Apply idle classification
if max_cpu < CPU_THRESHOLD and daily_net_in < NETWORK_THRESHOLD:
idle_instances.append({
'instance_id': instance_id,
'instance_type': instance_type,
'name': name,
'max_cpu_percent': round(max_cpu, 2),
'avg_cpu_percent': round(avg_cpu, 2),
'daily_network_in_mb': round(daily_net_in / 1048576, 2),
'launch_time': instance['LaunchTime'].isoformat(),
'estimated_monthly_waste': estimate_cost(instance_type)
})
if idle_instances:
total_waste = sum(i['estimated_monthly_waste'] for i in idle_instances)
message = f"Found {len(idle_instances)} idle EC2 instances\n"
message += f"Estimated monthly waste: ${total_waste:.2f}\n\n"
for inst in sorted(idle_instances,
key=lambda x: x['estimated_monthly_waste'],
reverse=True):
message += (
f" {inst['instance_id']} ({inst['instance_type']}) "
f"- {inst['name']}\n"
f" CPU max: {inst['max_cpu_percent']}% | "
f" Net: {inst['daily_network_in_mb']} MB/day | "
f" Cost: ${inst['estimated_monthly_waste']}/mo\n"
)
# Publish to SNS
sns.publish(
TopicArn=SNS_TOPIC_ARN,
Subject=f'[Idle EC2] {len(idle_instances)} instances - '
f'${total_waste:.0f}/mo waste detected',
Message=message
)
return {
'statusCode': 200,
'body': json.dumps({
'idle_count': len(idle_instances),
'total_monthly_waste': sum(
i['estimated_monthly_waste'] for i in idle_instances
)
})
}
def estimate_cost(instance_type):
"""Approximate monthly on-demand cost for common instance types (us-east-1)."""
pricing = {
't3.micro': 7.59, 't3.small': 15.18, 't3.medium': 30.37,
't3.large': 60.74, 't3.xlarge': 121.47,
'm5.large': 69.12, 'm5.xlarge': 138.24, 'm5.2xlarge': 276.48,
'm5.4xlarge': 552.96,
'm6i.large': 69.12, 'm6i.xlarge': 138.24, 'm6i.2xlarge': 276.48,
'c5.large': 61.20, 'c5.xlarge': 122.40, 'c5.2xlarge': 244.80,
'r5.large': 90.72, 'r5.xlarge': 181.44, 'r5.2xlarge': 362.88,
'c6i.large': 61.20, 'c6i.xlarge': 122.40,
'r6i.large': 90.72, 'r6i.xlarge': 181.44,
}
return pricing.get(instance_type, 100.00) # Default estimate
EventBridge Rule (CloudFormation/Terraform)
# CloudFormation snippet
IdleDetectionRule:
Type: AWS::Events::Rule
Properties:
Name: idle-ec2-detection-daily
Description: "Triggers idle EC2 detection Lambda daily at 6 AM UTC"
ScheduleExpression: "cron(0 6 * * ? *)"
State: ENABLED
Targets:
- Arn: !GetAtt IdleDetectionLambda.Arn
Id: idle-detection-target
# Terraform equivalent
resource "aws_cloudwatch_event_rule" "idle_detection" {
name = "idle-ec2-detection-daily"
description = "Triggers idle EC2 detection Lambda daily at 6 AM UTC"
schedule_expression = "cron(0 6 * * ? *)"
}
resource "aws_cloudwatch_event_target" "idle_detection" {
rule = aws_cloudwatch_event_rule.idle_detection.name
target_id = "idle-detection-target"
arn = aws_lambda_function.idle_detection.arn
}
resource "aws_lambda_permission" "allow_eventbridge" {
statement_id = "AllowEventBridgeInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.idle_detection.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.idle_detection.arn
}
IAM Policy for the Lambda
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:DescribeVolumes",
"cloudwatch:GetMetricStatistics",
"cloudwatch:ListMetrics"
],
"Resource": "*"
},
{
"Effect": "Allow",
"Action": "sns:Publish",
"Resource": "arn:aws:sns:us-east-1:123456789012:idle-instance-alerts"
},
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
}
]
}
Advanced Detection Patterns
Correlating with VPC Flow Logs
CloudWatch metrics give you instance-level aggregates, but VPC Flow Logs reveal whether an instance is communicating with anything meaningful. An instance might show network activity solely from health checks or NTP synchronization, which does not indicate useful work.
# Query VPC Flow Logs via CloudWatch Logs Insights
aws logs start-query \
--log-group-name /aws/vpc/flowlogs \
--start-time $(date -u -d '7 days ago' +%s) \
--end-time $(date -u +%s) \
--query-string '
filter dstAddr = "10.0.1.45"
| stats sum(bytes) as totalBytes by srcAddr
| sort totalBytes desc
| limit 20
'
If an instance's only inbound traffic comes from the VPC CIDR range on port 443 (health checks from an ALB) and NTP servers, it is a strong idle signal even if raw NetworkIn numbers look non-trivial.
Using AWS Cost Explorer for Validation
Cross-reference your detection findings with Cost Explorer to validate the financial impact:
aws ce get-cost-and-usage \
--time-period Start=2026-06-01,End=2026-07-01 \
--granularity MONTHLY \
--metrics UnblendedCost \
--filter '{
"Dimensions": {
"Key": "RESOURCE_ID",
"Values": ["i-0abc123def456789"]
}
}' \
--group-by Type=DIMENSION,Key=USAGE_TYPE
Detecting Orphaned Resources Attached to Idle Instances
Idle instances often have attached resources that continue billing independently:
# Find Elastic IPs not attached to running instances
aws ec2 describe-addresses \
--query "Addresses[?AssociationId==null].{
AllocationId:AllocationId,
PublicIp:PublicIp
}" \
--output table
# Find unattached EBS volumes (former attachments from terminated instances)
aws ec2 describe-volumes \
--filters "Name=status,Values=available" \
--query "Volumes[*].{
VolumeId:VolumeId,
Size:Size,
Type:VolumeType,
Created:CreateTime
}" \
--output table
Unattached Elastic IPs cost $3.60/month each (since the February 2024 pricing change). Orphaned EBS volumes at $0.08/GB-month for gp3 can accumulate surprisingly fast across accounts.
Tag-Based Ownership Detection
When you find idle instances, determining the owner is critical for the stop/terminate decision. Use a combination of tags, CloudTrail, and instance metadata:
# Check who launched the instance (CloudTrail)
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=ResourceName,AttributeValue=i-0abc123def456789 \
--query "Events[?EventName=='RunInstances'].{
User:Username,
Time:EventTime
}" \
--output table
# Check instance tags for ownership
aws ec2 describe-instances \
--instance-ids i-0abc123def456789 \
--query "Reservations[0].Instances[0].Tags[?Key=='Owner' || Key=='Team' || Key=='Environment']" \
--output table
If your organization does not enforce mandatory tagging (at minimum: Owner, Team, Environment, and CostCenter), idle instance detection becomes an ownership nightmare. Implement AWS Organizations SCPs that deny
ec2:RunInstanceswithout required tags.
Real-World Cost Impact Analysis
Let us quantify what idle instance elimination looks like at different scales:
Startup (5-20 Engineers)
Typical idle inventory: 8-15 instances forgotten across dev/staging environments.
| Instance Type | Count | Monthly Waste | Annual Waste |
|---|---|---|---|
| t3.medium | 4 | $121.48 | $1,457.76 |
| m5.large | 3 | $207.36 | $2,488.32 |
| t3.large | 2 | $121.48 | $1,457.76 |
| r5.large | 1 | $90.72 | $1,088.64 |
| c5.xlarge | 2 | $244.80 | $2,937.60 |
| Total | 12 | $785.84 | $9,430.08 |
For a seed-stage startup with $50K/month burn, that is nearly 2% of monthly spend recoverable with a single afternoon of cleanup.
Mid-Market (50-200 Engineers)
Typical idle inventory: 40-80 instances across production-adjacent, dev, QA, and sandbox accounts.
| Category | Instance Count | Monthly Waste |
|---|---|---|
| Forgotten dev environments | 25 | $2,750 |
| Decommissioned staging | 12 | $1,900 |
| Old load test infrastructure | 8 | $2,200 |
| Orphaned CI/CD workers | 10 | $690 |
| Abandoned POC clusters | 6 | $1,400 |
| Total | 61 | $8,940 |
Annual waste: $107,280. This typically funds 1-2 additional headcount.
Enterprise (500+ Engineers)
At enterprise scale, multi-account sprawl makes manual detection impossible. Organizations commonly discover 200-500 idle instances representing $30,000-$80,000/month in waste during their first systematic audit.
Continuous Governance: Beyond One-Time Cleanup
Cleaning up idle instances once is necessary but insufficient. Without continuous governance, your environment will accumulate new zombies within weeks. Here is the framework for sustained waste prevention:
Automated Tagging Enforcement
Prevent untagged instances from being created:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "RequireTagsOnEC2",
"Effect": "Deny",
"Action": "ec2:RunInstances",
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"Null": {
"aws:RequestTag/Owner": "true",
"aws:RequestTag/Environment": "true",
"aws:RequestTag/ExpiresAt": "true"
}
}
}
]
}
TTL-Based Auto-Termination
For development and testing environments, implement time-to-live tags that trigger automatic cleanup:
# Lambda triggered daily - terminates expired instances
import boto3
from datetime import datetime
ec2 = boto3.client('ec2')
def lambda_handler(event, context):
response = ec2.describe_instances(
Filters=[
{'Name': 'instance-state-name', 'Values': ['running', 'stopped']},
{'Name': 'tag-key', 'Values': ['ExpiresAt']}
]
)
now = datetime.utcnow()
terminated = []
for reservation in response['Reservations']:
for instance in reservation['Instances']:
expires_tag = next(
(t['Value'] for t in instance.get('Tags', [])
if t['Key'] == 'ExpiresAt'), None
)
if expires_tag:
expires_at = datetime.fromisoformat(expires_tag)
if now > expires_at:
instance_id = instance['InstanceId']
# Create AMI before termination (safety net)
ec2.create_image(
InstanceId=instance_id,
Name=f"pre-termination-{instance_id}-{now.strftime('%Y%m%d')}",
NoReboot=True
)
ec2.terminate_instances(InstanceIds=[instance_id])
terminated.append(instance_id)
return {'terminated': terminated, 'count': len(terminated)}
Integrating with FinOps Tooling
For organizations that need continuous, automated detection across multiple cloud providers and accounts, purpose-built tools eliminate the operational overhead of maintaining custom scripts. CloudFinOps, for example, automatically scans connected AWS accounts for idle instances using the same metric thresholds described in this guide, classifies resources by waste severity, and surfaces actionable recommendations through a unified dashboard. This is particularly valuable when managing dozens of accounts where the Lambda-per-account approach becomes its own management burden.
Slack/Teams Integration for Accountability
Wire your detection Lambda to post findings in an engineering channel with owner mentions:
import requests
def notify_slack(idle_instances, webhook_url):
blocks = [{
"type": "header",
"text": {"type": "plain_text", "text": "Idle EC2 Instances Detected"}
}]
for inst in idle_instances[:10]: # Top 10 by cost
blocks.append({
"type": "section",
"text": {
"type": "mrkdwn",
"text": (
f"*{inst['instance_id']}* (`{inst['instance_type']}`)\n"
f"Name: {inst['name']} | Owner: @{inst.get('owner', 'unknown')}\n"
f"CPU max: {inst['max_cpu_percent']}% | "
f"Waste: *${inst['estimated_monthly_waste']}/mo*"
)
}
})
requests.post(webhook_url, json={"blocks": blocks})
FAQ
How long should I monitor an instance before declaring it idle?
A minimum of 14 days is recommended for most workloads. This captures weekly patterns (cron jobs running only on weekends, weekly batch processes) and typical sprint cycles. For instances in production VPCs or those with ambiguous purposes, extend to 30 days. The only exception is instances in dedicated sandbox/development accounts with clear ExpiresAt tags, where 7 days is sufficient.
What about instances that are idle 95% of the time but critical during the remaining 5%?
These are candidates for replacement with on-demand or scheduled scaling, not termination. Common patterns include batch processing (replace with AWS Batch or Step Functions), scheduled reports (replace with Lambda), and periodic data syncs (replace with Fargate Spot tasks). If the workload genuinely needs an EC2 instance, consider a Spot Instance with a launch template that scales from zero via a scheduled ASG action. You pay only for the hours you need instead of all 720 hours in a month.
Does this approach work for instances behind Auto Scaling Groups?
Partially. Individual instances in an ASG should not be manually stopped or terminated since the ASG will simply replace them. Instead, analyze the ASG's scaling metrics to determine if the minimum capacity is set too high. If your ASG's minimum is 4 but CloudWatch shows the group never scales above 2 instances of actual load, reduce the minimum. For ASGs that are entirely idle (the workload they serve has been decommissioned), delete the entire ASG rather than individual instances.
What is the difference between "idle" and "underutilized"?
Idle means the instance is doing effectively zero useful work across all metrics (CPU, network, disk). Underutilized means the instance is doing real work but is dramatically over-provisioned for that work. An m5.4xlarge running at 8% average CPU and 6 GB of 64 GB RAM used is not idle but is underutilized. The correct action for idle instances is stop or terminate. The correct action for underutilized instances is rightsizing, either to a smaller instance type in the same family or to a different family entirely (e.g., moving from memory-optimized r5 to general-purpose m5 if memory usage is low).
How do I handle idle instances that are part of a Reserved Instance or Savings Plan commitment?
You cannot recover the committed spend, but you can recover the utility. Options include repurposing the instance for actual workloads (move a workload from on-demand to the committed capacity), selling unused RIs on the AWS Marketplace (if convertible), or accepting the sunk cost and preventing future over-commitment. The key insight is that keeping an idle instance running to "use" a reservation is fallacious. The reservation cost is already spent. If you can move another on-demand workload onto the reserved capacity, you save the on-demand cost of that workload while the idle instance gets terminated. Track RI utilization via the AWS Cost Explorer RI Utilization report and aim for 90%+ coverage across your committed fleet.

