Host a Static Site on EC2 with Terraform (VPC, Optional ALB, Session Manager)
For static sites, S3 + CloudFront is usually the better default. This post points at a small Terraform demo and pulls a few excerpts from main.tf, variables.tf, iam.tf, and user_data.tftpl. Full layout: tf-aws-ec2-static-demo (local path ~/workspace/jdevto/tf-aws-ec2-static-demo if you keep it beside this blog repo). S3 + CloudFront with Terraform: art0018.
Overview
The demo provisions a VPC, nginx on Amazon Linux 2023, index.html (AZ + private IP from IMDSv2), and robots.txt. use_alb=false (default): one instance in one public subnet; clients hit :80 on the instance public IP (CIDR from allowed_http_cidr). use_alb=true: internet ALB across az_count ≥ 2 public subnets; az_count instances in private subnets (one per AZ), NAT for egress, no instance public IP; instances register to one target group with HTTP / health check expecting 200; instance SG allows :80 from the ALB SG and from the VPC CIDR. enable_ssm=true (default): Session Manager with AmazonSSMManagedInstanceCore—no SSH in the template.
locals {
subnet_count = var.use_alb ? var.az_count : 1
instance_count = var.use_alb ? var.az_count : 1
}
variable "az_count" {
default = 3
# ...
validation {
condition = var.az_count >= 1 && var.az_count <= 6 && (!var.use_alb || var.az_count >= 2)
error_message = "az_count must be between 1 and 6, and at least 2 when use_alb is true (ALB requirement)."
}
}
Why EC2?
Learning stack (Terraform + VPC + optional ELB), policy that prefers VPC-hosted sites, temporary lift-and-shift, or a box that might grow non-static behavior later. None of that makes EC2 the default for a pure static site.
VPC
Every instance is in a VPC and a subnet (EC2-Classic is gone for new accounts). The demo creates its own VPC—public subnets always; private subnets + NAT when use_alb=true.
Architecture
use_alb = false
+----------+ :80 (public IP) +----------------+
| Clients | ------------------> | EC2 (nginx) |
+----------+ +----------------+
use_alb = true
+----------+ :80 +-----+ :80 +----------------+
| Clients | ------> | ALB | ------> | EC2 × az_count |
+----------+ +-----+ | (private) |
+----------------+
NAT is billed when the ALB path is on. Repeated curl to the ALB can show different AZ / private IP in index.html as backends rotate. user_data fills those fields via IMDSv2 after nginx is up:
IMDS_TOKEN=$(curl -sS -X PUT "http://169.254.169.254/latest/api/token" \
-H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
PRIVATE_IP=$(curl -sS -H "X-aws-ec2-metadata-token: $IMDS_TOKEN" \
"http://169.254.169.254/latest/meta-data/local-ipv4")
AZ=$(curl -sS -H "X-aws-ec2-metadata-token: $IMDS_TOKEN" \
"http://169.254.169.254/latest/meta-data/placement/availability-zone")
main.tf — placement and bootstrap:
resource "aws_instance" "web" {
count = local.instance_count
subnet_id = var.use_alb ? aws_subnet.private[count.index].id : aws_subnet.public[0].id
vpc_security_group_ids = [aws_security_group.instance.id]
user_data = templatefile("${path.module}/user_data.tftpl", { enable_ssm = var.enable_ssm })
user_data_replace_on_change = true
iam_instance_profile = var.enable_ssm ? aws_iam_instance_profile.ssm[0].name : null
# ...
}
main.tf — instance ingress (ALB on) and target group health check:
dynamic "ingress" {
for_each = var.use_alb ? [1] : []
content {
description = "HTTP from ALB security group (forwarded client traffic)"
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb[0].id]
}
}
dynamic "ingress" {
for_each = var.use_alb ? [1] : []
content {
description = "HTTP from VPC for ALB health checks and internal probes"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = [aws_vpc.main.cidr_block]
}
}
resource "aws_lb_target_group" "web" {
count = var.use_alb ? 1 : 0
port = 80
protocol = "HTTP"
protocol_version = "HTTP1"
# ...
health_check {
enabled = true
protocol = "HTTP"
port = "traffic-port"
path = "/"
matcher = "200"
interval = 15
timeout = 10
healthy_threshold = 2
unhealthy_threshold = 3
}
}
Session Manager
Agent + AmazonSSMManagedInstanceCore; user_data.tftpl via templatefile so #!/bin/bash is at column 0 (indented heredoc in Terraform often breaks the shebang). With enable_ssm, the template pulls the SSM Agent RPM from S3 and starts the service. Use the AMI’s curl—do not dnf install curl on AL2023 (conflicts with curl-minimal, set -e aborts before nginx). Egress: NAT or SSM VPC endpoints. Docs: agent install, status. Your principal needs ssm:StartSession; Session Manager plugin for CLI. After apply, wait for Online in Fleet Manager; with use_alb=true, use instance_ids or ssm_start_session_command (first instance). %{ if enable_ssm ~} … %{ endif ~} wraps the SSM block in the template.
iam.tf:
resource "aws_iam_role_policy_attachment" "ssm_core" {
count = var.enable_ssm ? 1 : 0
role = aws_iam_role.ssm[0].name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
user_data.tftpl (SSM path):
#!/bin/bash
# Column-0 shebang required: indented Terraform heredocs break #!/bin/bash and cloud-init may skip the script.
set -eux
# Do not `dnf install curl` here: AL2023 ships curl-minimal; installing full curl conflicts and aborts the whole script under set -e.
SSM_RPM=""
case "$(uname -m)" in
x86_64) SSM_RPM="https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_amd64/amazon-ssm-agent.rpm" ;;
aarch64) SSM_RPM="https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_arm64/amazon-ssm-agent.rpm" ;;
*) echo "Unsupported arch for SSM agent RPM"; exit 1 ;;
esac
curl -sS -o /tmp/amazon-ssm-agent.rpm "$SSM_RPM"
dnf install -y /tmp/amazon-ssm-agent.rpm
rm -f /tmp/amazon-ssm-agent.rpm
systemctl enable amazon-ssm-agent
systemctl restart amazon-ssm-agent
robots.txt
Crawler hint file—not security. The demo writes:
User-agent: *
Allow: /
user_data.tftpl:
cat >/usr/share/nginx/html/robots.txt <<'ROBOTS'
User-agent: *
Allow: /
ROBOTS
Run it
git clone https://github.com/jdevto/tf-aws-ec2-static-demo.git
cd tf-aws-ec2-static-demo
terraform init && terraform apply
ALB (needs ≥2 AZs; default az_count is 3):
terraform apply -var="use_alb=true"
# terraform apply -var="use_alb=true" -var="az_count=2"
Wait 1–2 minutes after first boot for user_data. user_data_replace_on_change = true replaces instances when the template changes. Variables: repo README.
variable "use_alb" {
type = bool
default = false
}
variable "allowed_http_cidr" {
type = string
default = "0.0.0.0/0"
}
variable "enable_ssm" {
type = bool
default = true
}
terraform output verify_commands
# use_alb=false:
curl -sS "http://$(terraform output -raw instance_public_ip)/robots.txt"
# use_alb=true (no instance public IP):
# curl -sS "$(terraform output -raw website_url_alb)/robots.txt"
terraform output -raw ssm_start_session_command
Troubleshooting
| Issue | Check |
|---|---|
| Timeout / connection refused |
use_alb=true: use ALB URL, not instance IP. false: SG, allowed_http_cidr, user_data, instance_public_ip. |
| ALB unhealthy / 502 | SG :80 from ALB + VPC; / returns 200. cloud-init-output.log: failed user_data (e.g. dnf install curl vs curl-minimal). |
| Apply limits | Other region/account or limit increase. |
| SSM offline / denied |
enable_ssm, agent time, ssm:StartSession, outbound to SSM (NAT or endpoints). |
Top comments (0)