VOOZH about

URL: https://dev.to/jajera/host-a-static-site-on-ec2-with-terraform-vpc-optional-alb-1ba9

⇱ Host a Static Site on EC2 with Terraform (VPC, Optional ALB) - DEV Community


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

Google: robots.txt.

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

References