← Back to DevBytes

EC2 Best Practices: Cost, Security, and Performance

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:

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:

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

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles