Recipe: EC2 & Security Groups
Launching a single EC2 instance the right way touches almost every core AWS concept: a dynamically resolved AMI, a least-privilege security group, an SSH key pair, a user-data bootstrap script, and outputs that surface the instance’s address. This recipe assembles all of those into one self-contained, copy-paste configuration you can terraform apply today. It works identically with Terraform 1.5+ and OpenTofu, since every resource used belongs to the standard hashicorp/aws provider.
What we are building
The stack provisions a web server in your account’s default VPC. A security group permits inbound HTTP from anywhere and SSH only from a CIDR you control, while allowing all outbound traffic. The instance pulls the latest Amazon Linux 2023 AMI, registers an SSH key pair, runs a user-data script to install and start a web server on first boot, and exposes its public IP and DNS name as outputs.
Provider and inputs
Pin the provider and expose the handful of values you will want to change per environment as variables. Restricting SSH access to your own IP is the single most important security decision here, so it is a required input with no default.
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
provider "aws" {
region = var.region
}
variable "region" {
type = string
default = "us-east-1"
}
variable "instance_type" {
type = string
default = "t3.micro"
}
variable "ssh_cidr" {
description = "CIDR allowed to reach SSH (your IP, e.g. 203.0.113.10/32)"
type = string
validation {
condition = can(cidrhost(var.ssh_cidr, 0))
error_message = "ssh_cidr must be a valid CIDR block, e.g. 203.0.113.10/32."
}
}
variable "public_key_path" {
type = string
default = "~/.ssh/id_ed25519.pub"
}
Looking up the AMI and network
Hard-coding an AMI ID is a classic mistake — they differ by region and change with every patch release. The aws_ami data source resolves the newest Amazon Linux 2023 image owned by Amazon, and aws_vpc/aws_subnets find a place to put the instance without you managing a VPC.
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
data "aws_vpc" "default" {
default = true
}
data "aws_subnets" "default" {
filter {
name = "vpc-id"
values = [data.aws_vpc.default.id]
}
}
The security group
A security group is a stateful virtual firewall: because return traffic is allowed automatically, you only declare the directions you initiate. Following least privilege, SSH is locked to var.ssh_cidr while HTTP stays open for a public website. Using dedicated aws_vpc_security_group_*_rule resources (rather than inline ingress blocks) lets you add or remove rules without forcing the whole group to be recreated.
resource "aws_security_group" "web" {
name = "web-sg"
description = "Allow HTTP from anywhere and SSH from a trusted CIDR"
vpc_id = data.aws_vpc.default.id
tags = { Name = "web-sg" }
}
resource "aws_vpc_security_group_ingress_rule" "http" {
security_group_id = aws_security_group.web.id
description = "HTTP"
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "ssh" {
security_group_id = aws_security_group.web.id
description = "SSH from trusted CIDR"
cidr_ipv4 = var.ssh_cidr
from_port = 22
to_port = 22
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "all" {
security_group_id = aws_security_group.web.id
description = "All outbound"
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
Warning: Never open SSH (port 22) to
0.0.0.0/0. Bots scan the entire IPv4 space continuously; an exposed SSH port will see brute-force attempts within minutes. Scope it to your IP, a bastion, or replace SSH entirely with AWS Systems Manager Session Manager.
Key pair, user data, and the instance
The user_data script runs once on first boot via cloud-init. Wrapping it in templatefile or a heredoc keeps it readable; here we install a web server and write a page. Passing user data through base64encode is optional in recent provider versions but is explicit and avoids surprises.
resource "aws_key_pair" "web" {
key_name = "web-key"
public_key = file(pathexpand(var.public_key_path))
}
locals {
user_data = <<-EOF
#!/bin/bash
set -euo pipefail
dnf install -y httpd
systemctl enable --now httpd
echo "<h1>Provisioned by Terraform on $(hostname -f)</h1>" > /var/www/html/index.html
EOF
}
resource "aws_instance" "web" {
ami = data.aws_ami.al2023.id
instance_type = var.instance_type
subnet_id = data.aws_subnets.default.ids[0]
vpc_security_group_ids = [aws_security_group.web.id]
key_name = aws_key_pair.web.key_name
user_data = local.user_data
user_data_replace_on_change = true
metadata_options {
http_tokens = "required" # enforce IMDSv2
}
root_block_device {
volume_size = 8
volume_type = "gp3"
encrypted = true
}
tags = { Name = "web-server" }
}
Setting user_data_replace_on_change = true means editing the bootstrap script recreates the instance so the new script actually runs, and http_tokens = "required" enforces IMDSv2 to defend against SSRF-based credential theft.
Outputs
Surface the addresses you need to reach the box. Marking nothing sensitive here is fine — these are public values.
output "instance_id" {
value = aws_instance.web.id
}
output "public_ip" {
value = aws_instance.web.public_ip
}
output "public_dns" {
value = aws_instance.web.public_dns
}
Apply it
Initialize, then plan with your IP supplied.
terraform init
terraform plan -var="ssh_cidr=203.0.113.10/32"
terraform apply -var="ssh_cidr=203.0.113.10/32" -auto-approve
Output:
data.aws_ami.al2023: Read complete after 1s [id=ami-0abcd1234ef567890]
aws_security_group.web: Creating...
aws_key_pair.web: Creating...
aws_security_group.web: Creation complete after 2s [id=sg-0123456789abcdef0]
aws_instance.web: Creating...
aws_instance.web: Creation complete after 23s [id=i-0fae9c1b2d3e4f567]
Apply complete! Resources: 6 added, 0 changed, 0 destroyed.
Outputs:
instance_id = "i-0fae9c1b2d3e4f567"
public_dns = "ec2-203-0-113-25.compute-1.amazonaws.com"
public_ip = "203.0.113.25"
Give the instance a minute to run user data, then curl http://$(terraform output -raw public_ip) should return the provisioned HTML page.
Best Practices
- Restrict SSH ingress to a specific CIDR or, better, drop SSH entirely in favor of SSM Session Manager so no inbound port is exposed.
- Resolve AMIs with the
aws_amidata source instead of hard-coding region-specific image IDs. - Enforce IMDSv2 with
metadata_options { http_tokens = "required" }to mitigate credential exfiltration via SSRF. - Encrypt root volumes (
encrypted = true) and prefergp3over the oldergp2for better baseline performance and cost. - Use standalone
aws_vpc_security_group_*_ruleresources so rules can change without recreating the whole group. - Set
user_data_replace_on_change = trueso bootstrap edits actually re-run rather than silently drifting. - Validate inputs like
ssh_cidrwith avalidationblock to catch typos at plan time instead of after an apply.