Host a Static Site on EC2 with Terraform (VPC, Optional ALB)

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

Using GoAccess to View nginx Logs

Using GoAccess to View nginx Logs

GoAccess is a fast terminal and HTML log analyzer for nginx access logs (requests/sec, status codes, top URLs, referrers, user agents). This guide covers installation, optional S3 download, and typical run modes.

1. Overview

  • Install GoAccess (package manager, source, or jdevto/cli-tools script)
  • Optional: sync logs from S3 when they are not on the machine you analyze from
  • Run TUI, HTML report, live tail, or compressed input; use COMBINED or COMMON to match nginx log_format

Skip §4 if logs are already on disk.

2. Prerequisites

  • Readable nginx access logs (usually combined or common format)
  • sudo for distro package installs
  • S3 (optional): AWS CLI v2 and s3:GetObject / s3:ListBucket for the prefix you use

3. Install GoAccess

Fedora / RHEL / CentOS Stream

sudo dnf install -y goaccess

Debian / Ubuntu

sudo apt update && sudo apt install -y goaccess
goaccess --version

Newer or custom build: official build instructions.

jdevto/cli-tools installer (optional)

Builds from the official tarball; details and env vars in install_goaccess.md.

bash <(curl -s https://raw.githubusercontent.com/jdevto/cli-tools/main/scripts/install_goaccess.sh) install
bash <(curl -s https://raw.githubusercontent.com/jdevto/cli-tools/main/scripts/install_goaccess.sh) uninstall

4. Optional: Download nginx Logs from S3

Single file

mkdir -p ~/nginx-logs
aws s3 cp s3://my-bucket/path/to/access.log ~/nginx-logs/access.log

Key is .gz only: download, decompress, or stream without extracting:

aws s3 cp s3://my-bucket/path/to/access.log.gz ~/nginx-logs/access.log.gz
gzip -d ~/nginx-logs/access.log.gz
# or: zcat ~/nginx-logs/access.log.gz | goaccess - --log-format=COMBINED

Prefix (many files)

mkdir -p ~/nginx-logs
aws s3 sync s3://my-bucket/nginx-logs/ ~/nginx-logs/

Replace bucket and prefix. If you only have *.gz, after sync use zcat ~/nginx-logs/*.gz > ~/nginx-logs/merged-access.log (order across files may not be chronological) or decompress then merge plain *.log.

Merge plain logs (optional)

cat ~/nginx-logs/*.log > ~/nginx-logs/merged-access.log

5. Run GoAccess

Match --log-format to nginx (COMBINED is the usual default).

Terminal UI

goaccess /var/log/nginx/access.log --log-format=COMBINED

HTML report

goaccess /var/log/nginx/access.log --log-format=COMBINED -o report.html --no-parsing-spinner
xdg-open report.html

Live log

tail -f /var/log/nginx/access.log | goaccess - --log-format=COMBINED

Common log format

goaccess /var/log/nginx/access.log --log-format=COMMON

Custom log_format: map fields with GoAccess custom format tokens.

Compressed on disk

zcat /var/log/nginx/access.log.*.gz | goaccess - --log-format=COMBINED

6. Summary: Copy-Paste

sudo dnf install -y goaccess
mkdir -p ~/nginx-logs && aws s3 sync s3://YOUR_BUCKET/YOUR_PREFIX/ ~/nginx-logs/   # optional
goaccess /var/log/nginx/access.log --log-format=COMBINED
goaccess /var/log/nginx/access.log --log-format=COMBINED -o report.html --no-parsing-spinner && xdg-open report.html
tail -f /var/log/nginx/access.log | goaccess - --log-format=COMBINED

7. Troubleshooting

Parsed 0 lines / wrong numbers: Log line shape ≠ COMBINED/COMMON. Compare head -1 to nginx log_format and adjust --log-format.

Permission denied on log: sudo goaccess ..., fix group on log files, or copy the log to your home directory.

S3 Access Denied: IAM on bucket/prefix; check AWS_PROFILE / role.

Live tail idle: Confirm nginx access_log path and read permissions on the active file.

8. References

Telefonlarda 5G'yi kullanabilmek için şebeke ayarı gerekiyor

Türkiye, 1 Nisan itibarıyla 5G mobil teknolojisini devreye alıyor. Uyumlu telefon ve sim kart sahipleri, bu tarih itibarıyla 5G kapsama alanında hızlı internet hizmetini kullanabilecek. 5G, mobil iletişimde devrim yaratarak, daha yüksek hızlar ve daha iyi bağlantı sağlayacak.