← Back to DevBytes

EC2: Complete Setup and Configuration Guide

What is Amazon EC2?

Amazon Elastic Compute Cloud (EC2) is a core AWS service that provides resizable, secure compute capacity in the cloud. In simple terms, EC2 lets you rent virtual servers — called instances — running on Amazon's physical infrastructure. You choose the operating system, CPU, memory, storage, and networking configuration, then launch the instance and connect to it just like any physical server. You pay only for the compute time you actually consume.

EC2 underpins virtually every modern cloud workload. From simple web hosting to massive distributed data processing clusters, from development and test environments to production microservices orchestrations, EC2 is the foundational building block. It eliminates the need to procure, rack, cable, and maintain physical hardware, compressing what used to take weeks into minutes.

Core Concepts You Must Understand

Before diving into setup, grasp these fundamental EC2 building blocks:

Why EC2 Matters: Key Use Cases and Benefits

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

EC2 isn't just another server rental service — it's the elastic, programmable backbone of cloud-native architecture. Here's why it matters:

Complete Setup and Configuration Walkthrough

This section walks through launching, connecting to, and configuring an EC2 instance from scratch — using the AWS CLI, which is the preferred tool for repeatable, automated setups. We'll cover both the CLI approach and critical Console steps.

Prerequisites

Ensure you have these before starting:

Step 1: Create a Key Pair

Key pairs are your primary authentication mechanism for Linux instances. Generate one using the CLI:

# Create a new key pair named 'my-app-keypair' and save the private key locally
aws ec2 create-key-pair \
    --key-name my-app-keypair \
    --key-type rsa \
    --key-format pem \
    --query "KeyMaterial" \
    --output text > my-app-keypair.pem

# Set correct permissions on the private key file (CRITICAL)
chmod 400 my-app-keypair.pem

# Verify the key pair exists
aws ec2 describe-key-pairs --key-names my-app-keypair

Important: The .pem file is your only copy of the private key. AWS does not store it. Lose it, and you lose access to instances launched with this key pair. Store it securely — consider AWS Secrets Manager or a hardware security module for production.

Step 2: Create a Security Group

Security groups define allowed inbound and outbound traffic. Let's create one that permits SSH from your IP and HTTP/HTTPS from anywhere:

# Create the security group in your default VPC (or specify --vpc-id)
aws ec2 create-security-group \
    --group-name web-server-sg \
    --description "Security group for web server - allows SSH, HTTP, HTTPS" \
    --vpc-id vpc-xxxxxxxxxxxxx   # Replace with your actual VPC ID

# Get the security group ID for later use
SG_ID=$(aws ec2 describe-security-groups \
    --filters Name=group-name,Values=web-server-sg \
    --query "SecurityGroups[0].GroupId" \
    --output text)
echo "Security Group ID: $SG_ID"

# Add inbound rules
aws ec2 authorize-security-group-ingress \
    --group-id $SG_ID \
    --protocol tcp \
    --port 22 \
    --cidr 203.0.113.25/32   # Replace with YOUR actual IP address
    # Use --cidr $(curl -s ifconfig.me)/32 to dynamically fetch your IP

aws ec2 authorize-security-group-ingress \
    --group-id $SG_ID \
    --protocol tcp \
    --port 80 \
    --cidr 0.0.0.0/0

aws ec2 authorize-security-group-ingress \
    --group-id $SG_ID \
    --protocol tcp \
    --port 443 \
    --cidr 0.0.0.0/0

# Optional: add ICMP for ping debugging
aws ec2 authorize-security-group-ingress \
    --group-id $SG_ID \
    --protocol icmp \
    --port -1 \
    --cidr 203.0.113.25/32

Production note: Never use 0.0.0.0/0 for SSH (port 22). Always restrict SSH to known IP ranges — VPN CIDRs, office egress IPs, or bastion host IPs. For HTTP/HTTPS, 0.0.0.0/0 is expected for public-facing web servers.

Step 3: Launch the EC2 Instance

Now launch an instance using a publicly available AMI. We'll use Amazon Linux 2023, a t3.micro (free tier eligible), attach our security group and key pair:

# Find the latest Amazon Linux 2023 AMI ID for your region
AMI_ID=$(aws ec2 describe-images \
    --owners amazon \
    --filters "Name=name,Values=al2023-ami-2023.*-kernel-6.1-x86_64" \
              "Name=state,Values=available" \
              "Name=architecture,Values=x86_64" \
              "Name=virtualization-type,Values=hvm" \
    --query "Images | sort_by(@, &CreationDate)[-1].ImageId" \
    --output text)
echo "Using AMI: $AMI_ID"

# Launch the instance
INSTANCE_ID=$(aws ec2 run-instances \
    --image-id $AMI_ID \
    --instance-type t3.micro \
    --key-name my-app-keypair \
    --security-group-ids $SG_ID \
    --subnet-id subnet-xxxxxxxxxxxxx   # Replace with your subnet ID
    --associate-public-ip-address \
    --block-device-mappings '[
        {
            "DeviceName": "/dev/xvda",
            "Ebs": {
                "VolumeSize": 20,
                "VolumeType": "gp3",
                "DeleteOnTermination": true,
                "Encrypted": true
            }
        }
    ]' \
    --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=web-server-prod}]' \
    --metadata-options 'HttpTokens=required,HttpPutResponseHopLimit=2' \
    --query "Instances[0].InstanceId" \
    --output text)
echo "Instance ID: $INSTANCE_ID"

# Wait for the instance to reach 'running' state
aws ec2 wait instance-running --instance-ids $INSTANCE_ID

# Get the public IP address
PUBLIC_IP=$(aws ec2 describe-instances \
    --instance-ids $INSTANCE_ID \
    --query "Reservations[0].Instances[0].PublicIpAddress" \
    --output text)
echo "Public IP: $PUBLIC_IP"

Let's break down what's happening in this launch command:

Step 4: Connect to Your Instance via SSH

With the public IP and key pair, connect securely:

# Basic SSH connection
ssh -i my-app-keypair.pem ec2-user@$PUBLIC_IP

# More explicit connection with host key checking
ssh -i my-app-keypair.pem \
    -o StrictHostKeyChecking=accept-new \
    -o IdentitiesOnly=yes \
    ec2-user@$PUBLIC_IP

# If you need to troubleshoot connection issues, use verbose mode
ssh -vvv -i my-app-keypair.pem ec2-user@$PUBLIC_IP

Connection troubleshooting checklist:

Step 5: Initial System Configuration

Once connected, perform essential baseline configuration. Here's a script that covers security hardening, package updates, and basic monitoring:

# Inside the EC2 instance, run these commands

# 1. Update all packages (Amazon Linux 2023 uses dnf)
sudo dnf update -y
sudo dnf upgrade -y

# 2. Install essential tools
sudo dnf install -y \
    amazon-ec2-utils \
    amazon-cloudwatch-agent \
    htop \
    iotop \
    jq \
    git \
    docker

# 3. Start and enable Docker if needed
sudo systemctl enable docker
sudo systemctl start docker
sudo usermod -aG docker ec2-user  # Allow ec2-user to run docker without sudo

# 4. Configure automatic security updates
sudo dnf install -y dnf-automatic
sudo systemctl enable dnf-automatic.timer
sudo systemctl start dnf-automatic.timer

# 5. Set hostname (persistent across reboots)
sudo hostnamectl set-hostname web-server-prod

# 6. Configure timezone
sudo timedatectl set-timezone UTC

# 7. Enable and start the CloudWatch agent
sudo systemctl enable amazon-cloudwatch-agent
sudo systemctl start amazon-cloudwatch-agent

# 8. Disable unused services (harden the system)
sudo systemctl disable --now rpcbind.socket 2>/dev/null || true
sudo systemctl disable --now rpcbind.service 2>/dev/null || true

# 9. Set up a basic firewall with iptables or nftables
# Amazon Linux 2023 uses nftables by default
sudo dnf install -y iptables-services
# Note: Security Groups handle most filtering; OS-level firewall adds defense in depth

Step 6: Deploy a Sample Application (Nginx + Simple Web App)

Let's deploy a real application to make this instance useful. We'll set up Nginx with a simple Node.js backend proxied behind it:

# Install Nginx and Node.js
sudo dnf install -y nginx nodejs npm

# Create a simple Node.js application directory
sudo mkdir -p /opt/webapp
sudo chown ec2-user:ec2-user /opt/webapp
cd /opt/webapp

# Create package.json and app.js
cat > package.json << 'EOF'
{
  "name": "simple-webapp",
  "version": "1.0.0",
  "description": "EC2 demo application",
  "main": "app.js",
  "scripts": {
    "start": "node app.js"
  },
  "dependencies": {
    "express": "^4.18.2"
  }
}
EOF

# Install dependencies
npm install

# Create the application file
cat > app.js << 'EOF'
const express = require('express');
const os = require('os');
const app = express();
const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.json({
    message: 'Hello from EC2!',
    instanceId: process.env.INSTANCE_ID || 'local',
    hostname: os.hostname(),
    uptime: Math.floor(os.uptime()),
    timestamp: new Date().toISOString(),
    requestHeaders: req.headers
  });
});

app.get('/health', (req, res) => {
  res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString() });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
EOF

# Create a systemd service file for the Node.js app
sudo tee /etc/systemd/system/webapp.service > /dev/null << 'EOF'
[Unit]
Description=Simple Web Application
After=network.target

[Service]
Type=simple
User=ec2-user
WorkingDirectory=/opt/webapp
ExecStart=/usr/bin/node /opt/webapp/app.js
Restart=always
RestartSec=10
Environment=PORT=3000
Environment=INSTANCE_ID=from-metadata
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
EOF

# Reload systemd, enable and start the service
sudo systemctl daemon-reload
sudo systemctl enable webapp.service
sudo systemctl start webapp.service

# Verify it's running
sudo systemctl status webapp.service
curl http://localhost:3000/health

Now configure Nginx as a reverse proxy:

# Configure Nginx
sudo tee /etc/nginx/conf.d/webapp.conf > /dev/null << 'EOF'
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;

    # Security headers
    add_header X-Frame-Options "DENY" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # Proxy to Node.js backend
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }

    # Health check endpoint (no caching)
    location /health {
        proxy_pass http://127.0.0.1:3000/health;
        proxy_cache_bypass 1;
        add_header Cache-Control "no-store, no-cache, must-revalidate";
    }
}
EOF

# Remove default server block if it conflicts
sudo rm -f /etc/nginx/conf.d/default.conf /etc/nginx/default.d/default.conf 2>/dev/null || true

# Test and reload Nginx
sudo nginx -t
sudo systemctl enable nginx
sudo systemctl reload nginx

# Test the full stack
curl http://localhost/
curl http://localhost/health

At this point, if you navigate to http://PUBLIC_IP in a browser (assuming your security group allows port 80), you'll see the JSON response from the Node.js application.

Step 7: Configure CloudWatch Monitoring and Logs

Production instances need proper monitoring. Configure the CloudWatch agent to collect system metrics and application logs:

# Create CloudWatch agent configuration file
sudo tee /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json > /dev/null << 'EOF'
{
  "agent": {
    "metrics_collection_interval": 60,
    "run_as_user": "root"
  },
  "metrics": {
    "append_dimensions": {
      "InstanceId": "${aws:InstanceId}",
      "ImageId": "${aws:ImageId}"
    },
    "metrics_collected": {
      "mem": {
        "measurement": [
          {"name": "mem_used_percent", "rename": "MemoryUtilization"}
        ]
      },
      "disk": {
        "measurement": [
          {"name": "disk_used_percent", "rename": "DiskUtilization"}
        ],
        "resources": ["/"]
      },
      "netstat": {
        "measurement": [
          {"name": "tcp_connection_count", "rename": "TCPConnections"}
        ]
      }
    }
  },
  "logs": {
    "logs_collected": {
      "files": {
        "collect_list": [
          {
            "file_path": "/var/log/nginx/access.log",
            "log_group_name": "/ec2/webapp/nginx/access",
            "log_stream_name": "{instance_id}",
            "timezone": "UTC"
          },
          {
            "file_path": "/var/log/nginx/error.log",
            "log_group_name": "/ec2/webapp/nginx/error",
            "log_stream_name": "{instance_id}",
            "timezone": "UTC"
          },
          {
            "file_path": "/var/log/messages",
            "log_group_name": "/ec2/webapp/system/messages",
            "log_stream_name": "{instance_id}",
            "timezone": "UTC"
          }
        ]
      }
    }
  }
}
EOF

# Start the CloudWatch agent with the configuration
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
    -a fetch-config \
    -m ec2 \
    -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json \
    -s

# Verify the agent is running
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
    -a status

Now you can view instance metrics (CPU, memory, disk, network) in CloudWatch Metrics and application logs in CloudWatch Logs.

Production-Grade Configuration Patterns

Pattern 1: User Data for Bootstrapping

Instead of manually running setup commands, embed them in User Data — a script that runs at first boot. This is the foundation of immutable infrastructure:

# User Data script (paste into --user-data when launching, or specify via file)
cat > user-data.sh << 'EOF'
#!/bin/bash
set -e

# Update system
dnf update -y

# Install packages
dnf install -y nginx nodejs npm git htop

# Create application
mkdir -p /opt/webapp
cat > /opt/webapp/app.js << 'APPEOF'
const express = require('express');
const app = express();
app.get('/', (req, res) => res.json({ status: 'ok', instance: process.env.INSTANCE_ID || 'unknown' }));
app.listen(3000);
APPEOF

cd /opt/webapp && npm init -y && npm install express

# Create systemd service
cat > /etc/systemd/system/webapp.service << 'SERVEOF'
[Unit]
Description=Web Application
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/webapp
ExecStart=/usr/bin/node /opt/webapp/app.js
Restart=always
Environment=PORT=3000
[Install]
WantedBy=multi-user.target
SERVEOF

systemctl daemon-reload
systemctl enable webapp.service
systemctl start webapp.service

# Configure nginx
cat > /etc/nginx/conf.d/default.conf << 'NGXEOF'
server {
    listen 80;
    location / { proxy_pass http://127.0.0.1:3000; }
}
NGXEOF

systemctl enable nginx
systemctl start nginx

echo "Bootstrap complete at $(date)" >> /var/log/user-data.log
EOF

# Launch with user data
aws ec2 run-instances \
    --image-id $AMI_ID \
    --instance-type t3.micro \
    --key-name my-app-keypair \
    --security-group-ids $SG_ID \
    --user-data file://user-data.sh \
    --subnet-id subnet-xxxxxxxxxxxxx

Pattern 2: IAM Instance Profile for Secure AWS Access

Never hardcode AWS credentials on an EC2 instance. Instead, attach an IAM role (instance profile) that grants the necessary permissions:

# Create an IAM role with an assume role policy for EC2
aws iam create-role \
    --role-name ec2-webapp-role \
    --assume-role-policy-document '{
        "Version": "2012-10-17",
        "Statement": [{
            "Effect": "Allow",
            "Principal": {"Service": "ec2.amazonaws.com"},
            "Action": "sts:AssumeRole"
        }]
    }'

# Attach policies (example: S3 read access and CloudWatch write)
aws iam attach-role-policy \
    --role-name ec2-webapp-role \
    --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess

aws iam attach-role-policy \
    --role-name ec2-webapp-role \
    --policy-arn arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy

# Create instance profile
aws iam create-instance-profile \
    --instance-profile-name ec2-webapp-profile

aws iam add-role-to-instance-profile \
    --instance-profile-name ec2-webapp-profile \
    --role-name ec2-webapp-role

# Launch instance with the instance profile
aws ec2 run-instances \
    --image-id $AMI_ID \
    --instance-type t3.micro \
    --key-name my-app-keypair \
    --security-group-ids $SG_ID \
    --iam-instance-profile Name=ec2-webapp-profile \
    --subnet-id subnet-xxxxxxxxxxxxx

Now from within the instance, the AWS CLI and SDKs automatically retrieve temporary credentials via the instance metadata service — no configuration needed.

Pattern 3: Elastic IP for Persistent Public Address

When you stop and restart an instance, its public IP changes (unless it's in a VPC with a persistent configuration). An Elastic IP gives you a static address:

# Allocate a new Elastic IP
aws ec2 allocate-address \
    --domain vpc \
    --tag-specifications 'ResourceType=elastic-ip,Tags=[{Key=Name,Value=web-server-eip}]'

# Associate it with your instance
aws ec2 associate-address \
    --instance-id $INSTANCE_ID \
    --allocation-id eipalloc-xxxxxxxxxxxxx

# Now the instance is reachable at the Elastic IP permanently
# Note: Elastic IPs are free while associated with a running instance;
# you're charged for unassociated or remapped EIPs

Best Practices for EC2

1. Security Hardening

2. High Availability and Resilience

3. Cost Optimization

4. Monitoring and Observability

5. Infrastructure as Code

Advanced Configuration: Auto Scaling Group Setup

Individual instances are fragile. For production, wrap your instances in an Auto Scaling group (ASG) that automatically replaces failed instances and scales based on demand:

# Create a launch template (modern replacement for launch configurations)
aws ec2 create-launch-template \
    --launch-template-name webapp-launch-template \
    --version-description "v1 - Amazon Linux 2023 with Node.js" \
    --launch-template-data '{
        "ImageId": "'"$AMI_ID"'",
        "InstanceType": "t3.micro",
        "KeyName": "my-app-keypair",
        "SecurityGroupIds": ["'"$SG_ID"'"],
        "IamInstanceProfile": {"Name": "ec2-webapp-profile"},
        "UserData": "'"$(base64 -w0 user-data.sh)"'",
        "BlockDeviceMappings": [{
            "DeviceName": "/dev/xvda",
            "Ebs": {
                "VolumeSize": 20,
                "VolumeType": "gp3",
                "Encrypted": true,
                "DeleteOnTermination": true
            }
        }],
        "MetadataOptions": {
            "HttpTokens": "required",
            "HttpPutResponseHopLimit": 2
        },
        "TagSpecifications": [{
            "ResourceType": "instance",
            "Tags": [{"Key": "Name", "Value": "webapp-asg-instance"}]
        }]
    }'

# Create the Auto Scaling group
aws autoscaling create-auto-scaling-group \
    --auto-scaling-group-name webapp-asg \
    --launch-template LaunchTemplateName=webapp-launch-template,Version='$Latest' \
    --min-size 2 \
    --max-size 10 \
    --desired-capacity 2 \
    --vpc-zone-identifier "subnet-aaaa,subnet-bbbb,subnet-cccc" \
    --health-check-type ELB \
    --health-check-grace-period 300 \
    --tags "Key=Name,Value=webapp-asg,PropagateAtLaunch=true" \
    --default-cooldown 300

# Attach the ASG to a target group (assuming ALB and target group exist)
aws autoscaling attach-load-balancer-target-groups \
    --auto-scaling-group-name webapp-asg \
    --target-group-arns arn:aws:elasticloadbalancing:region:account:targetgroup/webapp-tg/xxxxxxxx

# Create scaling policies
aws autoscaling put-scaling-policy \
    --auto-scaling-group-name webapp-asg \
    --policy-name scale-out-cpu \
    --policy-type TargetTrackingScaling \
    --target-tracking-configuration '{
        "TargetValue": 70.0,
        "PredefinedMetricSpecification": {
            "PredefinedMetricType": "ASGAverageCPUUtilization"
        }
    }'

With this configuration, the ASG maintains at least 2 healthy instances, scales up to 10 when CPU averages exceed 70%, and automatically replaces any instance that fails health checks. The launch template ensures every new instance is identically configured.

Cleaning Up Resources

When experimenting, remember to clean up to avoid unexpected charges:

# Terminate the instance
aws ec2 terminate-instances --instance-ids $INSTANCE_ID

# Delete the security group (after removing all instances using it)
aws ec2 delete-security-group --group-id $SG_ID

# Delete the key pair
aws ec2 delete-key-pair --key-name my-app-keypair
rm -f my-app-keypair.pem

# Release Elastic IP if you allocated one
aws ec2 release-address --allocation-id eipalloc-xxxxxxxxxxxxx

# Delete Auto Scaling group and launch template
aws autoscaling delete-auto-scaling-group \
    --auto-scaling-group-name webapp-asg \
    --force-delete
aws ec2 delete-launch-template \
    --launch-template-name webapp-launch-template

# Delete IAM resources
aws iam remove-role-from-instance-profile \
    --instance-profile

🚀 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