
My First Terraform EC2 Lab: 6 Things the Tutorials Skipped
I opened port 65022 in the security group and SSH still timed out. Six beginner Terraform lessons from building a Graviton EC2 lab, with the code.
In my last post I showed the one Terraform line that enforces IMDSv2. This post covers the rest of that lab: a small Graviton (ARM) EC2 instance with SSH on a custom port, built from scratch while I study for AWS Cloud Practitioner and work toward a DevOps role.
None of it is advanced. But each of these six points cost me some time to understand, and most beginner tutorials skip them.
TL;DR1. Look up the AMI with adatasource instead of hardcoding it, and match the architecture (ARM vs x86).
2.datablocks read,resourceblocks create and destroy. Knowing the difference keeps you from deleting things you don't own.
3. A security group can't change the port a service listens on.
4.user_dataruns once, so tell Terraform to replace the instance when it changes.
5. Validatesshdconfig before restarting it, or you can lock yourself out.
6. Pin versions, tag everything, and never commit the state file.
The setup:
t4g.nano, Amazon Linux 2023, us-west-2, default VPC, SSH on port 65022 from my IP only, IMDSv2 required, encrypted gp3 root volume.1. Don't hardcode the AMI, and match the architecture
AMI IDs are different in every Region and change every time Amazon publishes a new build. A hardcoded
ami-0abc... breaks when you switch Regions and goes stale quickly.A
data "aws_ami" lookup always finds the latest matching image:1
2
3
4
5
6
7
8
9
10
11
12
13
14
data "aws_ami" "al2023_arm" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-2023.*-arm64"]
}
filter {
name = "architecture"
values = ["arm64"]
}
}The catch:
t4g instances run on AWS Graviton, which is ARM. An x86_64 AMI won't launch on them. The instance type and the AMI architecture have to match, which is why both filters say arm64.2. data reads, resource creates
This sounds trivial, but it changed how I read Terraform code:
data: something that already exists, like the default VPC or the AMI above. Terraform only reads it and never changes it.resource: something Terraform creates, owns, and destroys withterraform destroy.
1
2
3
4
5
6
7
8
data "aws_vpc" "default" {
default = true # read-only: the VPC every Region comes with
}
resource "aws_security_group" "ssh" {
vpc_id = data.aws_vpc.default.id # created and owned by Terraform
# ...
}If I'd written the VPC as a
resource, Terraform would have tried to create a new one. Every time you write a block, ask yourself: "do I own this, or do I just need to point at it?"3. A security group can't move a port
I wanted SSH on
65022 instead of 22. My first thought was "open 65022 in the security group, done". It isn't done.A security group is a firewall. It decides which traffic is allowed in. It has no control over which port
sshd listens on. With only the security group change, port 65022 is open, nothing is listening there, and SSH times out.The service itself has to be reconfigured. I did it at first boot with
user_data:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
set -euo pipefail
# AL2023's sshd_config Includes sshd_config.d/*.conf at the top,
# and for sshd the first value read wins.
echo "Port ${ssh_port}" > /etc/ssh/sshd_config.d/10-custom-port.conf
# SELinux only lets sshd bind to labeled SSH ports. AL2023 ships in
# permissive mode, but label the port anyway if the tool is installed.
if command -v semanage >/dev/null 2>&1; then
semanage port -a -t ssh_port_t -p tcp ${ssh_port} || true
fi
# Validate before restarting (see #5)
sshd -t
systemctl restart sshdThe file is a Terraform template (
user_data.sh.tftpl). templatefile() replaces ${ssh_port} before the script is uploaded, so the port is defined in one variable and both the security group and sshd use it.Honest note: a custom SSH port is noise reduction, not security. It cuts down log spam from bots scanning port 22. The real protections are key-only authentication and a security group that allows only my IP (
/32).4. user_data runs once
user_data runs once, as root, at first boot through cloud-init. If you edit it later and run terraform apply, the running instance never runs the new script.One argument fixes this:
1
2
3
4
5
6
7
8
9
10
resource "aws_instance" "lab" {
# ...
user_data = templatefile("${path.module}/user_data.sh.tftpl", {
ssh_port = var.ssh_port
})
# user_data only matters at first boot, so a change means a new instance
# instead of an update to a script that will never run again.
user_data_replace_on_change = true
}Replacing the instance is fine for a lab or for immutable infrastructure. For a server with state you care about, you'd move that configuration to a config management tool (Ansible is next on my list).
5. sshd -t before restarting
There's no console login on this box. If
sshd restarts with a broken config, SSH is the only way in, and it's gone.sshd -t tests the configuration and exits non-zero if anything is wrong. With set -euo pipefail at the top of the script, a failed test stops the script before systemctl restart sshd runs, so the old, working sshd keeps running.It's one line, and I now use it anywhere I change a service's config: validate, then reload.
6. The boring parts that matter
Pin your versions. A future major release of the AWS provider can't silently change behavior:
1
2
3
4
5
6
7
8
9
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0" # any 6.x, never 7.0
}
}
}Commit
.terraform.lock.hcl, because it records the exact provider version you tested with.Tag everything from the provider:
1
2
3
4
5
6
7
8
9
10
11
provider "aws" {
region = var.region
profile = var.aws_profile # no default: you have to choose which account to run in
default_tags {
tags = {
Project = "terraform-ec2-lab"
ManagedBy = "terraform"
}
}
}Every resource gets tagged automatically, so a forgotten lab is easy to find in the console. Leaving
aws_profile without a default is a small safety measure: Terraform won't quietly run against whichever account your shell happens to be using.Never commit the state file.
terraform.tfstate maps your code to real resource IDs and can contain secrets. My .gitignore:1
2
3
4
5
.terraform/
*.tfstate
*.tfstate.*
crash.log
*.tfplanKeep variables like your IP in
terraform.tfvars, and think twice before pushing that file to a public repo.And when you're done:
terraform destroy. A t4g.nano costs very little per hour, but forgotten resources add up.What's next
- Build a custom AMI from this instance so the configuration is baked in instead of applied at every boot.
- Attach an IAM role with a least-privilege S3 policy (see the previous post ).
- Move from
user_datato Ansible for anything more than a few lines.
Over to you: what's one Terraform lesson you only learned after it went wrong? I'm collecting these before I start learning in production.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article