Terraform in Practice #3 EC2 and Security Groups: SG Chaining Design and Booting with user_data

5 min read

We put the first compute on top of the previous part’s network. The single EC2 instance we build here is not the final shape — it is a verification step before being promoted to an Auto Scaling group in the next part. Even so, this part carries one of the core lessons of real-world work: how to design security groups. If you have ever seen what security groups look like after the console-era habit of “open it for now, clean it up later,” building the structure in code from the start is the answer this part offers.

Security group chaining: reference SGs, not CIDRs #

There is one principle: along the path traffic flows, make each security group reference another security group. For the internet → ALB → EC2 path, that means:

  • ALB SG: allow inbound 80 and 443 from the internet (0.0.0.0/0)
  • EC2 SG: allow inbound 80 only from the ALB SG

The key is that no CIDR appears on the EC2 side. The intent — “the web server accepts only traffic coming from the ALB” — stays right there in the code, and the rule needs no edits even if the subnet ranges change. We define the SG for next part’s ALB up front.

security.tf
# security.tf (new file)
resource "aws_security_group" "alb" {
  name_prefix = "myapp-${var.env}-alb-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_security_group" "web" {
  name_prefix = "myapp-${var.env}-web-"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]   # chaining
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  lifecycle {
    create_before_destroy = true
  }
}

Two things we learned in Basics #7 show up in practice right away. Security group names must be unique, and some rule changes trigger a replacement, so we pair name_prefix (random suffix) with create_before_destroy so that even a replacement leaves no gap. This combination is worth memorizing as the idiomatic pattern for security groups.

Why there is no SSH port #

You may have noticed there is no rule opening port 22. That is not a mistake — it is the design. The standard access path these days is SSM Session Manager, replacing SSH keys and open ports. Not a single port is opened, no key file gets distributed, and every session is logged. It does require an IAM role on the instance, which is the topic of #7, so this part’s instance stays in an “all ports closed” state for now, and we complete access in #7. If you urgently need to debug before then, you can add a temporary rule and remove it afterward — and even that change shows up in plan, which is exactly the advantage of managing this in code.

AMI lookup and user_data #

Using the data source pattern from Basics #4, we look up the latest AL2023 AMI and bring up nginx with a boot script.

compute.tf
# compute.tf (new file)
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-2023*-x86_64"]
  }
}

resource "aws_instance" "web" {
  ami                    = data.aws_ami.al2023.id
  instance_type          = "t3.micro"
  subnet_id              = aws_subnet.private["ap-northeast-2a"].id
  vpc_security_group_ids = [aws_security_group.web.id]

  user_data = <<-EOF
    #!/bin/bash
    dnf install -y nginx
    echo "myapp ${var.env} - $(hostname)" > /usr/share/nginx/html/index.html
    systemctl enable --now nginx
  EOF

  tags = { Name = "myapp-${var.env}-web" }
}

Three decisions are worth noting.

  1. The instance goes in a private subnet. Even though it is a web server, the thing that faces the internet directly is next part’s ALB; the server itself hides behind it — that is the point of the two-tier layout. Package installation (dnf) works thanks to the NAT from the previous part.
  2. user_data runs once, on first boot. And as we saw in Basics #7, changing user_data forces an instance replacement by default. That behavior fits the immutable-infrastructure approach — swap servers out instead of patching them — and next part’s Auto Scaling turns this property into an advantage.
  3. There is no key_name. We decided not to use SSH, so no key pair is created either.

Verification: there is no access path yet #

After apply the instance comes up, but it sits in a private subnet, so there is no way to check it in a browser yet. This awkward state is an intentional intermediate step. Seeing the instance pass its status checks (2/2 checks) in the console, and confirming the private IP with terraform state show aws_instance.web, is enough verification for this part. The access check happens in a browser next part, once the ALB exists.

Recap #

What we covered in this post:

  • Security groups chain by security group reference instead of CIDR. The intent — “only traffic coming from the ALB” — stays in the code
  • Security groups get name_prefix and create_before_destroy as a pair. Even a replacement leaves no gap
  • No SSH port is opened. Access gets completed in #7 with SSM Session Manager
  • The instance lives in a private subnet, and user_data configures the web server at boot. The “user_data change means replacement” property becomes an advantage next part
  • The private instance is in an intermediate state with no access path yet, and next part’s ALB becomes its entrance

In the next post (#4 ALB and Auto Scaling), we promote this single instance to an Auto Scaling group behind a load balancer. We move the instance definition into a launch template and confirm the first response in a browser.

X