Skip to content
Infrastructure as Code iac cloud 5 min read

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_ami data 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 prefer gp3 over the older gp2 for better baseline performance and cost.
  • Use standalone aws_vpc_security_group_*_rule resources so rules can change without recreating the whole group.
  • Set user_data_replace_on_change = true so bootstrap edits actually re-run rather than silently drifting.
  • Validate inputs like ssh_cidr with a validation block to catch typos at plan time instead of after an apply.
Last updated June 14, 2026
Was this helpful?