Terraform in Practice #3 EC2 and Security Groups: SG Chaining Design and Booting with user_data
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 (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 (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.
- 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.
- 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.
- 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.