CloudFinOps logo
CloudFinOps
AWS

AWS EC2 Idle Instance Detection: The Complete Guide to Finding Zombie VMs (2026)

Nishant Jain

Nishant Jain

Co-Founder & CTO · · 14 min read

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):

MetricIdle ThresholdNamespaceStatistic
CPUUtilization< 5% averageAWS/EC2Average
NetworkIn< 1 MB/dayAWS/EC2Sum
NetworkOut< 1 MB/dayAWS/EC2Sum
DiskReadOps< 100 ops/dayAWS/EC2Sum
DiskWriteOps< 100 ops/dayAWS/EC2Sum
NetworkPacketsIn< 1000/dayAWS/EC2Sum
EBSReadBytes< 10 MB/dayAWS/EBSSum
EBSWriteBytes< 10 MB/dayAWS/EBSSum

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.

Start Free Audit

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).

ConditionActionRationale
Idle > 30 days, no owner identified, no DNS/LB referencesTerminateNo evidence of purpose; snapshot EBS first
Idle > 14 days, owner identified, confirms not neededTerminateOwner-approved decommission
Idle > 7 days, owner identified, might be needed laterStopPreserves state; eliminates compute cost
CPU < 5% average but periodic spikes to 20-40%RightsizeWorkload exists but instance is over-provisioned
CPU < 5% average, steady low network (monitoring agent)Rightsize to t3.micro/t4g.nanoLightweight daemon; needs smallest possible instance
Part of ASG, consistently idleReduce ASG min/desiredLet the scaling policy handle it
Reserved Instance or Savings Plan committedRightsize or repurposeYou pay regardless; move workload to use commitment
Has Elastic IP attachedTerminate + release EIPIdle instance + unused EIP = double waste
Spot instance, idleTerminate immediatelyNo 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:

  1. DNS check: Does any Route 53 record or external DNS point to this instance's IP?
  2. Load balancer check: Is this instance registered in any ALB/NLB target group?
  3. Security group references: Do other security groups reference this instance's SG as a source?
  4. IAM role usage: Is the instance's IAM role referenced by other services (Lambda, ECS)?
  5. Elastic IP: Does it have an EIP that other services depend on?
  6. 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:

  1. EventBridge Rule: Triggers Lambda on a cron schedule (daily at 6 AM UTC)
  2. Lambda Function: Queries CloudWatch metrics for all running instances
  3. Classification: Applies threshold logic to categorize instances
  4. Notification: Sends findings to SNS (email), Slack webhook, or writes to DynamoDB
  5. 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:RunInstances without 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 TypeCountMonthly WasteAnnual Waste
t3.medium4$121.48$1,457.76
m5.large3$207.36$2,488.32
t3.large2$121.48$1,457.76
r5.large1$90.72$1,088.64
c5.xlarge2$244.80$2,937.60
Total12$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.

CategoryInstance CountMonthly Waste
Forgotten dev environments25$2,750
Decommissioned staging12$1,900
Old load test infrastructure8$2,200
Orphaned CI/CD workers10$690
Abandoned POC clusters6$1,400
Total61$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.

Related articles