EC2 Best Practices: Cost, Security, and Performance
Amazon EC2 (Elastic Compute Cloud) is the backbone of countless production workloads. Yet many teams treat EC2 instances like on-premises servers — launching them manually, forgetting to rightsize, and leaving security groups wide open. This tutorial walks through the three pillars of EC2 excellence: cost optimization, security hardening, and performance tuning. Each section includes actionable code examples you can run today.
What This Guide Covers
You'll learn how to choose the right instance family, apply automated savings plans, lock down network access, encrypt volumes, and squeeze maximum throughput out of your instances. The examples use AWS CLI, Terraform, and in-instance configuration — so you can apply them regardless of your toolchain.
Why These Three Pillars Matter
Cost, security, and performance are interdependent. An over-provisioned instance wastes money and expands your attack surface. A poorly secured instance can be compromised, driving up compute costs from cryptomining. A slow instance forces you to scale out unnecessarily, multiplying both cost and security exposure. Fixing them together creates a virtuous cycle.
Part 1: Cost Optimization
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →1.1 Rightsizing: Pick the Correct Instance Family
Rightsizing means matching your workload's CPU, memory, network, and storage patterns to the optimal instance type. Start by collecting utilization data with AWS Compute Optimizer or CloudWatch metrics, then act on the recommendations.
# View Compute Optimizer recommendations via CLI
aws compute-optimizer get-ec2-instance-recommendations \
--region us-east-1 \
--query 'instanceRecommendations[*].{CurrentType:currentInstanceType,Recommended:recommendedInstanceType, Savings:estimatedMonthlySavings}' \
--output table
Common rightsizing rules of thumb:
- CPU-bound workloads (video encoding, ML inference): Use compute-optimized instances (c7a, c6i) — avoid T-family burstable types for sustained CPU
- Memory-bound workloads (in-memory caches, SAP): Use memory-optimized (r7i, x2iedn) or high-memory instances
- General purpose (web servers, dev environments): m7a or m6i instances offer balanced ratios
- Burstable workloads (low-traffic staging, cron jobs): t3/t4g with unlimited mode disabled to cap costs
1.2 Purchase Options: Savings Plans, Reserved Instances, and Spot
The pricing model you choose often saves more money than downsizing an instance. Here's how to layer them:
# Create a Compute Savings Plan (1-year, partial upfront) via CLI
aws savingsplans create-savings-plan \
--savings-plan-offering-id "ec2-1year-partial-upfront" \
--commitment 0.50 \
--purchase-time "2026-01-01T00:00:00Z" \
--client-token "unique-token-$(date +%s)"
Savings Plans lock in a dollar-per-hour commitment across EC2, Fargate, and Lambda. Reserved Instances are instance-family-specific but offer higher discounts. Spot Instances suit fault-tolerant workloads — use them for CI/CD runners, batch jobs, and stateless web tiers.
# Terraform example: mixed instance policy for an ASG using Spot
resource "aws_autoscaling_group" "web" {
name = "web-asg"
mixed_instances_policy {
instances_distribution {
on_demand_base_capacity = 2
on_demand_percentage_above_base_capacity = 25
spot_allocation_strategy = "price-capacity-optimized"
}
launch_template {
launch_template_specification {
launch_template_id = aws_launch_template.web.id
version = "$Latest"
}
overrides {
instance_type = "m7a.large"
}
overrides {
instance_type = "c7a.large"
}
}
}
min_size = 4
max_size = 20
}
1.3 Stop Non-Production Instances on a Schedule
Dev, staging, and QA environments don't need to run 24/7. Use Instance Scheduler (a free AWS solution) or a simple Lambda function:
# Lambda function to stop instances tagged Environment=Dev at 8 PM UTC
import boto3
import datetime
def lambda_handler(event, context):
ec2 = boto3.client('ec2')
instances = ec2.describe_instances(
Filters=[
{'Name': 'tag:Environment', 'Values': ['Dev']},
{'Name': 'instance-state-name', 'Values': ['running']}
]
)
instance_ids = [
inst['InstanceId']
for res in instances['Reservations']
for inst in res['Instances']
]
if instance_ids:
ec2.stop_instances(InstanceIds=instance_ids)
print(f"Stopped {len(instance_ids)} dev instances")
1.4 Enable Cost Allocation Tags
Without tags, your AWS bill is an undecipherable lump sum. Enforce a strict tagging policy:
# Tag an instance with mandatory cost-center, owner, and environment
aws ec2 create-tags \
--resources i-0abc123def456 \
--tags Key=CostCenter,Value=eng-platform \
Key=Owner,Value=sre-team \
Key=Environment,Value=production \
Key=AutoStop,Value=false
Activate these tags in the AWS Billing Console → Cost Allocation Tags dashboard. It takes 24-48 hours for tags to appear in Cost Explorer.
1.5 Monitor and Alert on Cost Anomalies
# Create a cost anomaly monitor for EC2 spend
aws ce create-anomaly-monitor \
--anomaly-monitor '{
"MonitorName": "EC2-Spend-Anomaly",
"MonitorType": "SERVICE",
"MonitorSpecification": "ec2"
}'
# Create a subscription that alerts via SNS when spend exceeds $500 threshold
aws ce create-anomaly-subscription \
--anomaly-subscription '{
"SubscriptionName": "EC2-Over-500",
"Threshold": 500,
"Frequency": "DAILY",
"MonitorArnList": ["arn:aws:ce:us-east-1:123456789:anomalymonitor/..."],
"Subscribers": [{
"Address": "arn:aws:sns:us-east-1:123456789:cost-alerts",
"Type": "SNS"
}]
}'
Part 2: Security Best Practices
2.1 Security Groups: Least-Privilege Ingress/Egress
Security groups are stateful firewalls. The most common mistake is opening 0.0.0.0/0 for SSH or RDP. Instead, restrict by CIDR, use bastion hosts, or better yet — use SSM Session Manager to eliminate inbound SSH entirely.
# Security group that allows HTTP/HTTPS from the world, but SSH only from a bastion SG
aws ec2 create-security-group \
--group-name "app-sg" \
--description "Application security group" \
--vpc-id vpc-0abc123
aws ec2 authorize-security-group-ingress \
--group-id sg-0def456 \
--protocol tcp --port 443 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress \
--group-id sg-0def456 \
--protocol tcp --port 22 \
--source-group sg-0bastion789 # Only from bastion host SG
2.2 Use SSM Session Manager Instead of SSH
Session Manager lets you open a terminal to an EC2 instance without exposing port 22, without a public IP, and without managing SSH keys. It logs all commands to CloudTrail/S3.
# Attach the AmazonSSMManagedInstanceCore policy to the instance role
aws iam attach-role-policy \
--role-name "EC2-SSM-Role" \
--policy-arn "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
# Start a session (no SSH key required)
aws ssm start-session --target i-0abc123def456
# In Terraform, the required IAM policy document
data "aws_iam_policy_document" "ssm_trust" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role_policy_attachment" "ssm_core" {
role = aws_iam_role.instance_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
2.3 Encrypt EBS Volumes by Default
Unencrypted volumes expose data at rest. Enable EBS encryption by default at the account level — it's free and transparent to the instance.
# Enable EBS encryption by default for the entire region
aws ec2 enable-ebs-encryption-by-default --region us-east-1
# Verify the setting
aws ec2 get-ebs-encryption-by-default --region us-east-1
# Returns: { "EbsEncryptionByDefault": true }
# For existing unencrypted volumes, create encrypted copies
aws ec2 create-snapshot \
--volume-id vol-0unencrypted \
--description "Encrypted snapshot for migration"
# Then copy with encryption (specify a KMS key or use aws/ebs default)
aws ec2 copy-snapshot \
--source-region us-east-1 \
--source-snapshot-id snap-0original \
--encrypted \
--kms-key-id alias/aws/ebs \
--region us-east-1
2.4 Instance Profiles and Least-Privilege IAM Roles
Never hardcode AWS credentials in application code or user-data scripts. Attach an instance profile with a tightly scoped IAM role.
# Terraform: instance role that can only read from a specific S3 bucket
resource "aws_iam_role" "app_role" {
name = "app-instance-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy" "app_policy" {
name = "app-s3-read-only"
role = aws_iam_role.app_role.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:ListBucket"]
Resource = [
"arn:aws:s3:::app-bucket-prod",
"arn:aws:s3:::app-bucket-prod/*"
]
}]
})
}
resource "aws_iam_instance_profile" "app_profile" {
name = "app-instance-profile"
role = aws_iam_role.app_role.name
}
2.5 Patch, Scan, and Harden the AMI
Use AWS Systems Manager Patch Manager to keep instances updated. Pair it with Inspector for vulnerability scanning.
# Associate instances with a patch baseline via SSM
aws ssm create-association \
--name "AWS-RunPatchBaseline" \
--targets "Key=tag:PatchGroup,Values=prod-linux" \
--parameters "Operation=Scan,SnapshotId=ssm-patch-snapshot-$(date +%Y%m%d)" \
--schedule-expression "cron(0 3 ? * SUN *)"
# Run Inspector scan on all instances with a specific tag
aws inspector2 create-findings-report \
--report-format CSV \
--s3-destination '{"bucket":"audit-bucket","keyPrefix":"inspector-reports/"}'
2.6 Disable IMDSv1 and Enforce IMDSv2
The Instance Metadata Service (IMDS) v1 is vulnerable to SSRF attacks. Enforce IMDSv2 — it requires a session token, blocking most exploit chains.
# Launch instance with IMDSv2 required
aws ec2 run-instances \
--image-id ami-0abcdef123456 \
--instance-type m7a.large \
--metadata-options '{
"HttpTokens": "required",
"HttpPutResponseHopLimit": 2,
"HttpEndpoint": "enabled"
}' \
--key-name my-key \
--security-group-ids sg-0def456
# Modify existing instance metadata options (requires a stop/start or reboot)
aws ec2 modify-instance-metadata-options \
--instance-id i-0abc123 \
--http-tokens required \
--http-endpoint enabled
Part 3: Performance Optimization
3.1 Choose the Right Hypervisor and Generation
Modern instances (6th/7th generation) use AWS Nitro System, which offloads hypervisor functions to dedicated hardware. Nitro instances deliver near-bare-metal performance, higher IOPS, and lower latency. Always prefer c7a, m7i, r7i, or equivalent latest-gen types over older generations — the performance uplift is often 20-30% at the same price point.
3.2 EBS Volume Types and IOPS Configuration
Match your storage pattern to the right EBS volume type:
- gp3: Baseline 3000 IOPS / 125 MiB/s, burstable — best general-purpose choice
- io2 Block Express: Sub-millisecond latency, up to 256,000 IOPS for OLTP databases
- st1: Throughput-optimized HDD for streaming logs, ETL jobs
# Create a gp3 volume with custom IOPS and throughput
aws ec2 create-volume \
--volume-type gp3 \
--size 500 \
--iops 12000 \
--throughput 750 \
--availability-zone us-east-1a \
--encrypted
# Attach with appropriate block device mapping (Terraform)
resource "aws_instance" "db" {
ami = data.aws_ami.ubuntu.id
instance_type = "r7i.xlarge"
ebs_block_device {
device_name = "/dev/sdf"
volume_type = "io2"
volume_size = 100
iops = 20000
throughput = 800
}
ebs_optimized = true # Essential for max IOPS
}
3.3 Enhanced Networking: Elastic Fabric Adapter and DPDK
For high-throughput workloads (network appliances, HPC, real-time streaming), enable Elastic Fabric Adapter (EFA) or at minimum use the ena driver with tuning:
# Verify ENA driver version and enable multi-queue on Linux
ethtool -i eth0 | grep -i driver
# Output should show: driver: ena
# Set IRQ affinity for multi-queue NICs
for i in $(seq 0 $(($(nproc) - 1))); do
echo $((2**$i)) > /proc/irq/$i/smp_affinity
done
# For EFA-enabled instances (hpc6a, c6in, etc.), install libfabric
sudo apt-get update && sudo apt-get install -y libfabric-dev rdma-core
# Verify EFA device is present
ls -la /sys/class/infiniband/
3.4 Kernel and OS-Level Tuning
Stock AMI kernels aren't tuned for EC2. Apply these sysctl tweaks for network-heavy workloads:
# /etc/sysctl.d/90-ec2-performance.conf
net.core.rmem_max = 134217728
net.core.wmem_max = 134217728
net.ipv4.tcp_rmem = 4096 87380 134217728
net.ipv4.tcp_wmem = 4096 65536 134217728
net.ipv4.tcp_congestion_control = bbr
net.core.netdev_max_backlog = 25000
net.ipv4.tcp_mtu_probing = 1
# Apply immediately
sudo sysctl -p /etc/sysctl.d/90-ec2-performance.conf
For CPU-bound workloads, disable CPU sleep states and enable the performance governor:
# Disable C-states and set performance governor (Amazon Linux / Ubuntu)
for cpu in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do
echo performance > "$cpu"
done
# Verify
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
# Should output: performance
3.5 Instance Store (NVMe) for Temporary Data
Instance store volumes are physically attached NVMe drives offering extreme IOPS and throughput — but they're ephemeral. Use them for swap, temp tablespaces, or distributed cache tiers.
# List instance store NVMe devices
lsblk | grep nvme
# Format and mount instance store (example: nvme1n1)
sudo mkfs.xfs /dev/nvme1n1
sudo mkdir -p /mnt/ephemeral
sudo mount /dev/nvme1n1 /mnt/ephemeral
# Add to fstab with 'noauto' to prevent mount failures on instance stop
echo "/dev/nvme1n1 /mnt/ephemeral xfs defaults,noauto 0 0" | sudo tee -a /etc/fstab
3.6 CloudWatch Metrics and Performance Dashboards
Don't guess — instrument. Enable detailed monitoring and push custom metrics for visibility:
# Enable detailed monitoring (1-minute granularity) on an instance
aws ec2 monitor-instances --instance-ids i-0abc123
# Push a custom CPU metric from inside the instance (bash + cron)
#!/bin/bash
CPU_PCT=$(top -bn1 | grep "Cpu(s)" | awk '{print $2+$4}')
INSTANCE_ID=$(curl -s --connect-timeout 1 http://169.254.169.254/latest/meta-data/instance-id)
aws cloudwatch put-metric-data \
--namespace "Custom/AppMetrics" \
--metric-name "AppCPUUtilization" \
--value "$CPU_PCT" \
--unit "Percent" \
--dimensions "InstanceId=$INSTANCE_ID"
3.7 Auto Scaling Based on Performance Signals
# Terraform: Target tracking scaling policy based on custom metric
resource "aws_autoscaling_policy" "cpu_policy" {
name = "app-cpu-scaling"
autoscaling_group_name = aws_autoscaling_group.web.name
policy_type = "TargetTrackingScaling"
target_tracking_configuration {
predefined_metric_specification {
predefined_metric_type = "ASGAverageCPUUtilization"
}
target_value = 70.0
}
}
Conclusion
EC2 optimization isn't a one-time project — it's an operational habit. Rightsize regularly using Compute Optimizer data. Layer Savings Plans and Spot to cut your bill without sacrificing availability. Lock down security groups, enforce IMDSv2, and encrypt everything at rest. Tune the OS, choose modern Nitro instances, and instrument with CloudWatch so you're never flying blind. The code examples above give you a concrete starting point. Run through each section, apply what fits your stack, and treat cost, security, and performance as three dimensions of the same continuous improvement cycle.