Terraform Lesson 50 of 89

Terraform on AWS: EC2 Instances, Security Groups, Key Pairs, EBS Volumes & user-data

An EC2 instance is the oldest primitive in AWS and still the first thing most teams reach for: a whole Linux (or Windows) machine you rent by the second, on hardware you never see. The console makes it feel like one click — pick an AMI, pick a size, “Launch.” Terraform makes the truth explicit: an instance you can actually curl is never one resource. To stand up a web box you can reach from the internet you assemble a small graph — an AMI resolved (not hardcoded), an instance type, a subnet to land in, a key pair or an instance profile for access, a Security Group that decides who may knock, a root volume and often a separate EBS data volume, a user-data script so it boots already serving, and an Elastic IP so its address survives a reboot. The console hides that graph behind a wizard; Terraform is the graph, written down, reviewed before it exists, and destroyable in one command.

This lesson builds that graph for real. You will learn every argument that matters on aws_instance — how data.aws_ami with owners and filter blocks finds the latest Amazon Linux 2023 or Ubuntu so you never paste an ami-0abc… that vanishes next region; how instance_type, subnet_id, availability_zone and associate_public_ip_address place the box; how user_data (with the sharp-edged user_data_replace_on_change) bootstraps it; how metadata_options forces IMDSv2; and how iam_instance_profile gives it a role with no static keys. You will meet EBS two ways — the inline root_block_device versus a standalone aws_ebs_volume + aws_volume_attachment — and the gp3 vs gp2 vs io2 decision. You will meet key pairs (aws_key_pair, optionally minted by tls_private_key) and the reason to prefer SSM Session Manager over SSH entirely. And you will meet the part that trips up everyone: Security Groups, and the three different ways Terraform lets you write their rules — inline ingress/egress, the older standalone aws_security_group_rule, and the newer aws_vpc_security_group_ingress_rule — which you must never mix, plus the default-egress trap and the pattern of one SG referencing another as its source.

Why Terraform and not the console, aws ec2 run-instances, or CloudFormation? Because an instance is exactly the kind of multi-resource, order-sensitive, frequently-rebuilt thing that punishes manual work and rewards a plan you can read before you apply. The console cannot show you, before you click Launch, that changing the AMI or the user_data will replace the instance; terraform plan shows you the -/+ destroy and then create replacement in red. This lesson assumes you already have the AWS provider and a remote backend working, and it drops the instance into a network — for the full VPC story see Terraform on AWS: VPC, Subnets, Internet & NAT Gateways, Route Tables. When the box needs to sit behind a real load balancer that is Terraform on AWS: ELB — ALB, NLB & Target Groups, and the instance profile you attach here is authored properly in Terraform on AWS: IAM Roles, Policies & S3 Buckets.

What you’ll build

The scenario is week-one-on-AWS: “stand me up a Linux box in a public subnet, give it a stable public address, lock SSH to my own IP (or better, no SSH at all), attach an encrypted data disk, and have nginx already serving when it boots.” By the end you will have exactly that as .tf files you can commit, review and destroy — and you will understand every decision each resource forces.

The architecture in words: a small VPC (10.0.0.0/16) with one public subnet (10.0.1.0/24), an internet gateway and a route so the subnet can reach the world. A Security Group allows 80/443 from anywhere and 22 only from your /32. A data source resolves the newest Amazon Linux 2023 AMI. The EC2 instance references that AMI, the subnet and the SG, carries a key pair and an instance profile (so you can use SSM), enforces IMDSv2 via metadata_options, boots an nginx user-data script, and roots itself on an encrypted gp3 volume. A separate aws_ebs_volume attaches as a data disk, and an Elastic IP pins the address. That is the graph the diagram draws, left to right.

Here is the full bill of materials — every resource, what it models, and whether changing it in place is cheap or forces a replacement (destroy-and-recreate), which is the single most important column when you read a plan:

Resource Models Key reference Change-in-place or replace?
aws_vpc The 10.0.0.0/16 address space none In place (CIDR change replaces)
aws_internet_gateway The door to the internet VPC In place
aws_subnet The 10.0.1.0/24 segment + AZ VPC AZ/CIDR change replaces
aws_route_table (+ association) 0.0.0.0/0 → IGW VPC + IGW In place
aws_security_group The stateful firewall (empty shell) VPC In place (name/desc/vpc replaces)
aws_vpc_security_group_ingress_rule ×3 80 / 443 / 22 allow rules SG In place (most fields replace the rule)
aws_vpc_security_group_egress_rule Explicit allow-all egress SG In place
data.aws_ami The latest AL2023 image (read-only) none n/a (a lookup, not a resource)
tls_private_key + aws_key_pair An SSH key pair none Public key change replaces
aws_iam_role + _instance_profile The role the box assumes (for SSM) none In place (name replaces)
aws_instance The VM itself AMI, subnet, SG, key, profile AMI / user_data / type-in-some-cases replace
aws_ebs_volume + aws_volume_attachment A reattachable encrypted data disk subnet AZ + instance In place (grow only)
aws_eip The stable public IP instance + IGW In place

And the decision that sends people to Terraform in the first place — why not just click, script, or template it:

Approach How you describe the box Sees drift? Preview before change? Verdict for an EC2 graph
AWS Console Point-and-click Launch wizard No No Fine to learn once; unrepeatable, unreviewable
aws ec2 run-instances Imperative CLI flags No No Great for throwaways; no state, no plan
CloudFormation Declarative YAML/JSON Partial (change sets) aws cloudformation deploy --no-execute-changeset AWS-only; weaker multi-cloud/module ecosystem
Terraform (aws) Declarative HCL + state Yes (plan refreshes) Yes (terraform plan) The graph, versioned, previewed, destroyable

The single most important idea is that Terraform builds the dependency graph from your references, not from an order you write. You never tell it “create the Security Group before the instance”; the instance’s vpc_security_group_ids = [aws_security_group.web.id] is the edge, and subnet_id = aws_subnet.public.id is the next one. Terraform reads the references and orders the graph — which is why depends_on is almost never needed here.

Terraform builds an AWS EC2 box as a resource graph read left to right: terraform apply plus a user-data script feed a data.aws_ami lookup and a key pair, which launch an EC2 instance into a public subnet guarded by a Security Group; the instance enforces IMDSv2 and carries an instance profile, roots on an encrypted gp3 volume with an attached EBS data disk, and gets a static Elastic IP — then you curl the IP for the nginx page and open a shell over SSM with no SSH

Read it left to right: Terraform resolves the newest AMI and wires a key pair (or skips SSH for SSM), launches the instance into a public subnet behind a Security Group, boots it with a user-data script, attaches an encrypted gp3 data volume and pins an Elastic IP — and the far right is the payoff, a curl that returns the nginx page and a keyless SSM shell. The six numbered badges mark the decisions that trip people up, and the legend narrates each with a symptom and a fix.

The aws_instance resource and its arguments

The centre of gravity is aws_instance. It has dozens of arguments; the ones you set on nearly every real instance are below, grouped by what they control. Read this as the reference you keep open while writing the resource:

aws_instance argument Controls Typical value / note
ami Which image boots data.aws_ami.al2023.idnever a literal id
instance_type CPU / memory / network shape t3.micro (free-tier), m6i.large for real
subnet_id Which subnet (and thus AZ + VPC) it lands in aws_subnet.public.id
availability_zone Pin the AZ (usually inferred from subnet) omit — the subnet decides
vpc_security_group_ids The SGs attached to its ENI [aws_security_group.web.id]
key_name The SSH key pair to inject aws_key_pair.demo.key_name
associate_public_ip_address Give it an auto public IP true in a public subnet (EIP overrides it)
iam_instance_profile The role it assumes (name, not ARN) aws_iam_instance_profile.ec2.name
user_data / user_data_base64 First-boot bootstrap script heredoc bash (see below)
user_data_replace_on_change Recreate the box when the script changes true (default false — a trap)
metadata_options IMDS version + hop limit block forcing IMDSv2
root_block_device The OS disk (size, type, encryption) gp3, encrypted = true
ebs_block_device Inline extra disks (alternative to standalone) prefer standalone aws_ebs_volume
instance_market_options Spot request market_type = "spot" for interruptible work
placement_group / tenancy Physical placement cluster/spread groups; default tenancy
monitoring Detailed (1-min) CloudWatch false unless you need it (costs)
tags Cost allocation + identification Name at minimum

Two of those columns hide the whole reason to preview a plan. ami and user_data are replacement triggers by nature: you cannot swap the image or the boot script of a running instance in place, so Terraform destroys and recreates it. instance_type is subtler — it can often change in place (a stop/start under the hood) but for some virtualization or bare-metal transitions it replaces. Always read the plan; never assume a “small” edit is non-destructive.

The subnet_id is doing more than it looks. Because a subnet belongs to exactly one Availability Zone, choosing the subnet chooses the AZ — and that AZ must match any aws_ebs_volume you attach. You almost never set availability_zone on the instance directly; you let the subnet decide and read the AZ back with aws_subnet.public.availability_zone when you need it (for the data volume, exactly).

associate_public_ip_address = true gives the box an auto-assigned public IP at launch — convenient, but that address changes on every stop/start. The moment you want a durable address (for DNS, for an allow-list at a partner), you attach an Elastic IP, which supersedes the auto one. More on that below.

AMIs: resolve the latest, never hardcode an ID

An AMI (Amazon Machine Image) is the template the disk is cloned from. Its identifier — ami-0abc123… — is region-specific and mutable: the same logical image has a different id in us-east-1 and ap-south-1, and AWS publishes a new id every time it patches the base OS. Hardcode one and you get two failure modes: InvalidAMIID.NotFound when a teammate applies in another region, and a stale, unpatched OS that never updates because nothing tells Terraform the id moved.

The fix is a data.aws_ami lookup that resolves the newest matching image at plan time. You give it the owners (the account that publishes the image) and filter blocks (name pattern, architecture, virtualization), and most_recent = true to pick the latest:

data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]                       # AWS's own alias for Amazon Linux

  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]        # the AL2023 naming scheme
  }
  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
  filter {
    name   = "root-device-type"
    values = ["ebs"]
  }
}

# usage:  ami = data.aws_ami.al2023.id

The arguments on data.aws_ami:

Argument Purpose Note
most_recent Pick the newest match true — otherwise a multi-match errors
owners Restrict to a publisher ["amazon"], ["099720109477"] (Canonical), ["self"]
filter { name, values } Narrow by AMI attribute name, architecture, virtualization-type, state
name_regex Client-side regex on the name applied after the API filter
include_deprecated Consider deprecated AMIs default false (you want false)

The owner id and name pattern are the two things people get wrong. Here are the tuples you will actually use — memorise the first two:

OS owners filter name = "name" pattern Default SSH user
Amazon Linux 2023 ["amazon"] al2023-ami-2023.*-x86_64 ec2-user
Amazon Linux 2 ["amazon"] amzn2-ami-hvm-*-x86_64-gp2 ec2-user
Ubuntu 22.04 LTS ["099720109477"] ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-* ubuntu
Ubuntu 24.04 LTS ["099720109477"] ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-* ubuntu
RHEL 9 ["309956199498"] RHEL-9.*_HVM-*-x86_64-*-Hourly2-GP2 ec2-user
Debian 12 ["136693071363"] debian-12-amd64-* admin

099720109477 is Canonical’s AWS account; 309956199498 is Red Hat’s; these are stable, published values, not secrets. For arm64 (Graviton — cheaper and faster per rupee), swap the pattern to …-arm64 / …-aarch64 and pick a t4g/m7g/c7g instance type. A mismatch — an x86 AMI on a Graviton type or vice-versa — is the cryptic “instance immediately stopped” failure.

There is a second, even simpler pattern for Amazon Linux: read the AMI id straight from the SSM public parameter AWS maintains, which is always the latest:

data "aws_ssm_parameter" "al2023" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}
# usage:  ami = data.aws_ssm_parameter.al2023.value

Both are correct; data.aws_ami gives you filtering power and works for any publisher, while the SSM parameter is a one-liner for Amazon’s own images. The three ways to name an image, ranked:

Method HCL Pros Cons
Hardcoded id ami = "ami-0abc123…" None worth it Region-bound, goes stale, breaks teammates — avoid
data.aws_ami owners + filter + most_recent Any publisher; full filtering; latest Can shift under prod if unpinned
SSM public parameter data.aws_ssm_parameter One line; always latest AL Amazon images only

For a production fleet you want the resolution of data.aws_ami but the stability of a fixed id — resolve once, then pass the concrete ami_id into the module as a variable so a new base image never silently replaces running instances until you choose to bump it.

instance_type: what the box actually is

instance_type is a string like t3.micro or m6i.large, and the family letter tells you its shape. For a demo you want the free-tier-eligible t3.micro (or t2.micro in older regions); for production you size to the workload.

Family Examples Optimised for Use for
T (burstable) t3.micro, t3.medium, t4g.small Cheap, bursty CPU (credits) Dev boxes, low-traffic web, this demo
M (general) m6i.large, m7g.xlarge Balanced CPU:memory App servers, small databases
C (compute) c7i.xlarge, c7g.2xlarge High CPU:memory ratio Batch, encoding, game servers
R (memory) r6i.large, r7g.xlarge High memory:CPU Caches, in-memory DBs
I / D (storage) i4i.large, d3.xlarge High local NVMe/HDD NoSQL, data nodes, warehouses
G / P (accelerated) g5.xlarge, p4d.24xlarge GPU ML training/inference, rendering

The g suffix (t4g, m7g, c7g) means Graviton (arm64) — AWS’s own silicon, typically ~20% cheaper and often faster per rupee, but it needs an arm64 AMI. The i/a/d infixes name the CPU vendor (Intel/AMD) and disk. Not every type exists in every AZ; the classic InsufficientInstanceCapacity error is AWS out of that shape in that AZ, not a typo — try another AZ, another size, or a slightly older generation. Find what a region offers with aws ec2 describe-instance-type-offerings --location-type availability-zone --filters Name=location,Values=ap-south-1a.

user_data: bootstrapping the box at first boot

A raw instance is useless; you want it serving the moment it boots. user_data is a script the instance runs once, on first boot, executed by cloud-init as root. It can be a plain #!/bin/bash script or a #cloud-config YAML document — both are fine; bash is the common case. Here is a real one that installs and starts nginx on Amazon Linux 2023 and drops a home page:

resource "aws_instance" "web" {
  # …
  user_data = <<-EOF
    #!/bin/bash
    set -euxo pipefail
    dnf -y update
    dnf -y install nginx
    cat > /usr/share/nginx/html/index.html <<'HTML'
    <!doctype html>
    <h1>Hello from Terraform on EC2</h1>
    <p>This nginx page was installed by user_data at first boot.</p>
    HTML
    systemctl enable --now nginx
  EOF

  user_data_replace_on_change = true   # edit the script -> recreate the box
}

Three things to know. First, on AL2023 the package manager is dnf and the web root is /usr/share/nginx/html/; on Ubuntu it is apt-get install -y nginx and /var/www/html/. Second, user_data accepts either raw text (as above) or, via user_data_base64, a base64 blob — the two are mutually exclusive; use user_data_base64 = base64encode(...) when you need to ship gzipped or binary content. Third, and most important: user_data only runs on first boot. Change the script on a running instance and, by default, nothing happens — the box keeps its old config, and your plan shows no change. Setting user_data_replace_on_change = true makes Terraform treat an edited script as a reason to replace the instance so the new script actually runs. That is the behaviour you almost always want; the default (false) silently ignores your edit and is a classic “why didn’t my change take” trap.

When the script needs values from Terraform — a database endpoint, a bucket name, a token — use templatefile() so you interpolate cleanly instead of string-mashing:

user_data = templatefile("${path.module}/init.sh.tftpl", {
  bucket = aws_s3_bucket.assets.bucket
  region = var.region
})

The mechanisms compared:

Mechanism When it runs Tracked in state? Change → effect Best for
user_data (raw) Once, first boot No (an instance arg) Ignored unless replace_on_change Immutable first-boot config
user_data_base64 Once, first boot No Same Gzipped/binary payloads
templatefile() into user_data Once, first boot No Recreates when rendered text changes Config that needs TF values
SSM Run Command / State Manager Any time, re-runnable Separate service Re-applies in place Post-boot config, fleets, reruns
Golden AMI (Packer) Baked; boots ready AMI is the artifact Rebuild image + roll Fast boot, no first-boot risk

For anything that changes often, user_data is the wrong tool — bake a golden AMI with Packer (referenced via data.aws_ami with owners = ["self"]) or drive config with SSM, and keep user_data for the thin bootstrap.

Key pairs and access: SSH vs SSM Session Manager

To SSH into a Linux instance you inject a public key at launch via a key pair. In Terraform that is aws_key_pair, and you can either register an existing public key or have Terraform mint a fresh pair with the tls provider:

# Option A — register a key you already have:
resource "aws_key_pair" "demo" {
  key_name   = "kv-demo-key"
  public_key = file("~/.ssh/id_ed25519.pub")   # the PUBLIC half
}

# Option B — let Terraform generate one (handy in CI where no key exists):
resource "tls_private_key" "demo" {
  algorithm = "ED25519"                          # or "RSA" with rsa_bits = 4096
}
resource "aws_key_pair" "demo" {
  key_name   = "kv-demo-key"
  public_key = tls_private_key.demo.public_key_openssh
}

⚠️ With Option B the private key lands in state (tls_private_key.demo.private_key_pem), and state is plaintext JSON in your backend. Treat state as a secret, or prefer Option A for humans. The sources:

Key-pair source HCL Where the private key lives Use when
Existing public key public_key = file("~/.ssh/id_ed25519.pub") On your laptop only Humans; you already have a key
tls_private_key public_key = tls_private_key.x.public_key_openssh In state (sensitive) CI/ephemeral; no pre-existing key
Imported in console reference by key_name (a data source) Wherever you made it Shared org key (discouraged)

But the modern, better answer is to not open SSH at all. AWS Systems Manager Session Manager gives you an interactive shell through the SSM agent (pre-installed on AL2023/Ubuntu AMIs) with no inbound port, no key pair, and full CloudTrail audit of every session. All it needs is an instance profile carrying the AmazonSSMManagedInstanceCore policy and egress to the SSM endpoints. You start a shell with aws ssm start-session --target i-0abc….

Dimension SSH (key pair) SSM Session Manager
Inbound port 22 open (even to a /32) None — outbound to SSM only
Credential A private key to distribute + rotate IAM — the instance profile
Audit sshd logs on the box CloudTrail + session logs to S3/CW
Bastion needed? Often, for private instances No — works in private subnets
MFA / least-privilege Hard Native (IAM policies, ssm:StartSession)
Verdict Legacy / break-glass Default for new fleets

The demo below creates a key pair (so you can SSH if you insist) and an instance profile with the SSM policy, so you can compare — but it opens 22 only to your own /32, and the recommended path is aws ssm start-session.

Security Groups: the three ways to write rules — and which to trust

A Security Group is a stateful virtual firewall attached to an instance’s network interface (ENI). “Stateful” means you only write the request direction — if you allow inbound 443, the response is allowed back automatically; you do not write a return rule. Every rule is allow-only (there is no deny rule; absence is denial), and rules can reference a CIDR, another Security Group, or a prefix list.

Here is the trap that costs the most time: Terraform gives you three different, mutually incompatible ways to express the rules of one SG, and mixing any two of them on the same group makes every apply fight itself.

Form 1 — inline ingress/egress blocks on aws_security_group. Compact, everything in one resource, but the whole rule set is managed as one attribute — you cannot add a rule elsewhere without conflict:

resource "aws_security_group" "web_inline" {
  name        = "kv-web-inline"
  description = "web tier"
  vpc_id      = aws_vpc.this.id

  ingress {
    description = "HTTP"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  ingress {
    description = "SSH from my IP"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.my_ip_cidr]
  }
  egress {
    description = "all outbound"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"                 # -1 = every protocol
    cidr_blocks = ["0.0.0.0/0"]
  }
}

Form 2 — the older standalone aws_security_group_rule. One resource per rule, attached to an SG that has no inline rules. Lets you add rules from modules or loops, but it is being superseded:

resource "aws_security_group_rule" "https" {
  type              = "ingress"
  security_group_id = aws_security_group.web.id
  from_port         = 443
  to_port           = 443
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
}

Form 3 — the newer aws_vpc_security_group_ingress_rule / aws_vpc_security_group_egress_rule (AWS provider v5+). This is the one HashiCorp now recommends: exactly one CIDR (or one referenced SG) per rule, each rule gets its own stable id (so plans are clean and rules are individually taggable), and it uses ip_protocol/cidr_ipv4 naming:

resource "aws_vpc_security_group_ingress_rule" "http" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
  description       = "HTTP from anywhere"
}

resource "aws_vpc_security_group_egress_rule" "all" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"            # all protocols; omit from_port/to_port
}

The fields differ between the forms — this catches everyone migrating:

Field Inline / aws_security_group_rule aws_vpc_security_group_*_rule
Protocol protocol ("tcp", "-1") ip_protocol ("tcp", "-1")
CIDR (v4) cidr_blocks = [".../0"] (list) cidr_ipv4 = ".../0" (single)
CIDR (v6) ipv6_cidr_blocks cidr_ipv6
Source SG source_security_group_id referenced_security_group_id
Self self = true referenced_security_group_id = <self id>
Rules per resource many (aws_security_group_rule = 1) exactly 1
Ports for -1 ignored omit from_port/to_port

And the head-to-head on which to reach for:

Model Pros Cons Use when
Inline ingress/egress Compact; one resource Whole rule set is one attribute; can’t extend externally Small, fixed SGs owned by one module
aws_security_group_rule One-per-rule; loopable Older; noisier diffs; being superseded Existing code; migrating gradually
aws_vpc_security_group_*_rule Stable per-rule ids; taggable; recommended One CIDR per rule (more resources) New code — the default

The cardinal rule: pick ONE model per Security Group. If a group has an inline ingress block and an aws_security_group_rule (or a _vpc_security_group_ingress_rule) points at it, Terraform will, on every apply, delete whatever the other model added — your rules flap in and out and nothing stays stable. An aws_security_group you attach standalone rules to must declare no inline ingress/egress.

The default-egress trap

Here is the gotcha that locks people out of the internet. When AWS creates a Security Group it adds a default allow-all egress rule. But Terraform’s aws_security_group manages egress, and if your resource declares no egress (because you plan to use standalone rules) Terraform removes that default to make reality match your (empty) config. The result: a brand-new SG with no outbound at alldnf update in your user-data hangs, the SSM agent can’t call home, nothing works. The fix is to always add an explicit egress rule (the aws_vpc_security_group_egress_rule with ip_protocol = "-1" above), or keep an inline egress block. Never assume the AWS default survives Terraform.

Tiered Security Groups: reference an SG, not an IP

The production pattern for a multi-tier app is to make each SG’s source another SG, not a CIDR — so rules survive every scale event and IP change. The web tier accepts 443 from the ALB’s SG; the app tier accepts 8080 only from the web SG; the database accepts 5432 only from the app SG:

resource "aws_vpc_security_group_ingress_rule" "app_from_web" {
  security_group_id            = aws_security_group.app.id
  referenced_security_group_id = aws_security_group.web.id   # source = the web SG
  from_port                    = 8080
  to_port                      = 8080
  ip_protocol                  = "tcp"
}
Tier SG Allows Source Never
web 80 / 443 0.0.0.0/0 (or the ALB’s SG) direct DB access
app 8080 referenced_security_group_id = web SG the internet
db 5432 / 3306 referenced_security_group_id = app SG the web tier or 0.0.0.0/0

This is the single most reused idea in AWS networking: reference security groups, not addresses. Instances come and go, IPs churn, autoscaling doubles the fleet — the SG-to-SG rule never has to change.

EBS volumes: root and data disks, and the gp3 decision

Every instance has one root volume (the OS disk, cloned from the AMI) and zero or more data volumes you attach. Terraform gives you the root disk as an inline root_block_device block on the instance, and data disks two ways: inline ebs_block_device, or — better — a standalone aws_ebs_volume joined by an aws_volume_attachment.

The root volume, inline on the instance:

root_block_device {
  volume_type           = "gp3"     # not the old default gp2
  volume_size           = 20        # GiB
  encrypted             = true      # always
  throughput            = 125       # MiB/s (gp3 baseline)
  iops                  = 3000      # gp3 baseline; raise independently of size
  delete_on_termination = true      # root: yes, so you don't orphan disks
  tags = { Name = "kv-demo-root" }
}

A data volume as its own resource, so it can outlive the instance, be resized, or move to another box. The one hard rule: its availability_zone must match the instance’s AZ — which is the subnet’s AZ:

resource "aws_ebs_volume" "data" {
  availability_zone = aws_subnet.public.availability_zone   # MUST match the instance
  size              = 50
  type              = "gp3"
  iops              = 3000
  throughput        = 250
  encrypted         = true
  # kms_key_id      = aws_kms_key.ebs.arn                   # optional CMK; omit = aws/ebs key
  tags = { Name = "kv-demo-data" }
}

resource "aws_volume_attachment" "data" {
  device_name = "/dev/sdf"                 # Linux sees /dev/xvdf or an nvme name
  volume_id   = aws_ebs_volume.data.id
  instance_id = aws_instance.web.id
}

Why standalone over inline ebs_block_device? Because an inline block is part of the instance and dies with it; a standalone aws_ebs_volume is independent — you can terraform destroy the instance and keep the disk, resize it without touching the box, or detach and reattach it elsewhere. Inline vs standalone:

Aspect root_block_device ebs_block_device (inline) aws_ebs_volume + attachment
What it is The OS disk Extra disk, part of the instance Independent disk resource
Lifecycle Dies with the instance Dies with the instance Survives the instance
Resize In place (grow) In place (grow) In place; detachable
Reattach elsewhere No No Yes
Use for Always (the root) Quick throwaway scratch Real data disks

The type you pick is a price/performance dial. gp3 is the modern default and should be your reflex — it decouples IOPS and throughput from size (gp2 tied them to size) and costs ~20% less:

Volume type Media IOPS Throughput Cost Use for
gp3 SSD 3,000 baseline → 16,000 125 → 1,000 MiB/s ₹ (cheapest SSD) Default — boot + most workloads
gp2 SSD 3 IOPS/GiB (size-tied) up to 250 MiB/s ₹₹ Legacy; migrate to gp3
io1 / io2 SSD up to 64,000 (io2 Block Express 256k) high ₹₹₹₹ Latency-critical DBs; io2 for 99.999% durability
st1 HDD throughput-optimised up to 500 MiB/s Big sequential: logs, data warehouse
sc1 HDD cold up to 250 MiB/s ₹ (cheapest) Infrequent, archival

Two more disk rules to bank. Encryption is a reflex — set encrypted = true everywhere; with no kms_key_id you get the AWS-managed aws/ebs key for free, and you can point at a customer-managed KMS key (CMK) for key control and cross-account sharing. And the disk shows up under a different name inside Linux — you asked for /dev/sdf but Nitro instances present it as an NVMe device (/dev/nvme1n1); you still have to mkfs and mount it (or script that in user-data), because attaching a volume does not format it.

IMDSv2, instance profiles and the Elastic IP

Three small but load-bearing pieces finish the box.

IMDSv2 — the Instance Metadata Service is the 169.254.169.254 endpoint every instance can query for its own metadata and its role’s temporary credentials. IMDSv1 answered any HTTP GET, which is why a single SSRF bug (or a rogue container that can reach the link-local address) could read your instance role’s keys. IMDSv2 requires a session token obtained with a PUT, and a hop limit stops the token from being proxied off-box. Enforce it with metadata_options:

metadata_options {
  http_endpoint               = "enabled"
  http_tokens                 = "required"   # IMDSv2 only — reject IMDSv1
  http_put_response_hop_limit = 1            # token can't hop to a container/pod
  instance_metadata_tags      = "enabled"    # expose tags via IMDS (handy)
}
metadata_options field Values Guidance
http_endpoint enabled / disabled enabled (disable only if the box truly needs no metadata)
http_tokens optional / required required — this is what “IMDSv2 only” means
http_put_response_hop_limit 164 1 for a plain instance; 2 if a container must reach IMDS
instance_metadata_tags enabled / disabled enabled to read instance tags from IMDS

AL2023 already defaults to IMDSv2, but set it explicitly so the intent is in code and drift is caught.

Instance profile — the box authenticates to AWS with an IAM role delivered through an instance profile, so there are no static access keys on disk. You attach it by name via iam_instance_profile. The role’s trust policy allows ec2.amazonaws.com to assume it, and you attach whatever policies the app needs — here just SSM so Session Manager works:

data "aws_iam_policy_document" "ec2_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ec2" {
  name               = "kv-demo-ec2-role"
  assume_role_policy = data.aws_iam_policy_document.ec2_assume.json
}

resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.ec2.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "ec2" {
  name = "kv-demo-ec2-profile"
  role = aws_iam_role.ec2.name
}
# on the instance:  iam_instance_profile = aws_iam_instance_profile.ec2.name

The role and its policies are a lesson in themselves — authoring least-privilege policies, the trust vs permission split, and reusing the role for S3/other access is the subject of Terraform on AWS: IAM Roles, Policies & S3 Buckets. Here you attach one AWS-managed policy and move on.

Elastic IP — an auto-assigned public IP changes on every stop/start, which breaks DNS and partner allow-lists. An aws_eip is a static, account-owned IPv4 you pin to the instance. In a VPC set domain = "vpc" and depends_on the internet gateway so the association doesn’t race the route:

resource "aws_eip" "web" {
  domain     = "vpc"                     # replaces the deprecated `vpc = true`
  instance   = aws_instance.web.id
  depends_on = [aws_internet_gateway.this]
  tags = { Name = "kv-demo-eip" }
}
aws_eip argument Purpose Note
domain VPC vs EC2-Classic "vpc" always (Classic is gone)
instance Instance to associate or use a separate aws_eip_association
network_interface Attach to an ENI instead for multi-NIC / NAT setups
associate_with_private_ip Which private IP to map when the ENI has several
depends_on Force IGW-first ordering avoids a create-time race

⚠️ EIPs have a default quota of 5 per region, and — since Feb 2024 — every public IPv4 address bills ~$0.005/hour whether attached or not (~₹300/month). Release EIPs you are not using; an unattached EIP is billed and is the classic “why am I paying for nothing” line item.

A taste of Spot

For interruptible, fault-tolerant work (batch, CI runners, stateless workers) Spot instances run the same hardware at up to ~90% off, with the catch that AWS can reclaim them on two minutes’ notice. Request Spot inline with instance_market_options:

instance_market_options {
  market_type = "spot"
  spot_options {
    max_price                      = "0.0100"   # optional cap; omit to pay the spot price
    spot_instance_type             = "one-time"
    instance_interruption_behavior = "terminate"
  }
}

Never put a stateful singleton on Spot; for elastic capacity you would normally drive Spot through an Auto Scaling Group with a mixed-instances policy rather than a lone aws_instance, but the argument above is the primitive. The purchasing options, so you pick the right one:

Purchase option Discount vs on-demand Reclaimable? Commitment Use for
On-Demand baseline No None Steady, unpredictable, or short-lived workloads
Spot up to ~90% Yes (2-min notice) None Batch, CI, stateless, fault-tolerant fleets
Reserved Instance up to ~72% No 1 or 3 years, specific type Legacy steady-state; fixed shapes
Savings Plan up to ~72% No 1 or 3 years, $/hour Steady spend across changing types

Reserved Instances and Savings Plans are billing constructs (bought in the console or via aws_ec2_capacity_reservation / billing APIs), not an aws_instance argument — you launch normal on-demand instances and the commitment discounts them automatically.

Hands-on: build it with Terraform

This is the demo. Six files, one folder, run top to bottom. ⚠️ It creates real, billable AWS resources — an instance, EBS volumes and an Elastic IP. A t3.micro is free-tier-eligible for the first 12 months, but the EIP and EBS bill regardless; follow the destroy step at the end. Expect a few rupees for a few minutes.

Step 0 — prerequisites. Configure credentials, pick a region, make an SSH key if you want the SSH path, and grab your public IP so only you can reach port 22:

aws configure           # or export AWS_PROFILE / AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY
aws sts get-caller-identity          # confirm who you are

# create an SSH key pair if you don't have one (public half is the .pub)
test -f ~/.ssh/id_ed25519.pub || ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519

# your current public IP in CIDR form, for the SSH allow-rule
echo "$(curl -s https://checkip.amazonaws.com)/32"

Step 1 — the files. Create a folder and drop in these six files:

File Holds
versions.tf required_version, required_providers (aws + tls), the provider block
variables.tf Inputs: region, name prefix, instance type, your IP CIDR
network.tf The minimal public network: VPC, IGW, subnet, route table
security.tf The Security Group + standalone ingress/egress rules
main.tf The AMI lookup, key pair, IAM instance profile, instance, data volume, EIP
outputs.tf The Elastic IP, the SSH command, the SSM command

versions.tf — pin the providers and configure the region with default tags:

terraform {
  required_version = ">= 1.6"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
  }
}

provider "aws" {
  region = var.region
  default_tags {
    tags = {
      project    = "kv-ec2-demo"
      managed_by = "terraform"
    }
  }
}

variables.tf:

variable "region" {
  description = "AWS region"
  type        = string
  default     = "ap-south-1"
}

variable "name" {
  description = "Name prefix for every resource"
  type        = string
  default     = "kv-demo"
}

variable "instance_type" {
  description = "EC2 instance type (t3.micro is free-tier eligible)"
  type        = string
  default     = "t3.micro"
}

variable "my_ip_cidr" {
  description = "Your public IP in CIDR form for the SSH rule, e.g. 203.0.113.5/32"
  type        = string
}

network.tf — a minimal public network so the demo is self-contained. ⚠️ In real projects you would consume the VPC from the VPC lesson instead of inlining this:

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "this" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true
  tags = { Name = "${var.name}-vpc" }
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${var.name}-igw" }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.this.id
  cidr_block              = "10.0.1.0/24"
  availability_zone       = data.aws_availability_zones.available.names[0]
  map_public_ip_on_launch = true
  tags = { Name = "${var.name}-public" }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.this.id
  }
  tags = { Name = "${var.name}-public-rt" }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

security.tf — an empty SG plus standalone rules (the recommended model), and the explicit egress that replaces the default Terraform removes:

resource "aws_security_group" "web" {
  name        = "${var.name}-web-sg"
  description = "web tier: 80/443 world, 22 from my ip"
  vpc_id      = aws_vpc.this.id
  tags        = { Name = "${var.name}-web-sg" }
  # NO inline ingress/egress — rules are standalone below (pick ONE model)
}

resource "aws_vpc_security_group_ingress_rule" "http" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 80
  to_port           = 80
  ip_protocol       = "tcp"
  description       = "HTTP from anywhere"
}

resource "aws_vpc_security_group_ingress_rule" "https" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
  description       = "HTTPS from anywhere"
}

resource "aws_vpc_security_group_ingress_rule" "ssh" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = var.my_ip_cidr           # only YOU can SSH
  from_port         = 22
  to_port           = 22
  ip_protocol       = "tcp"
  description       = "SSH from my IP"
}

resource "aws_vpc_security_group_egress_rule" "all" {
  security_group_id = aws_security_group.web.id
  cidr_ipv4         = "0.0.0.0/0"
  ip_protocol       = "-1"                     # all — Terraform won't add this for you
  description       = "all outbound"
}

main.tf — the AMI lookup, key pair, instance profile, the instance, a data volume and the EIP:

# --- latest Amazon Linux 2023, resolved (never hardcoded) ---
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-2023.*-x86_64"]
  }
  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

# --- key pair (generated; you can SSH, but SSM is preferred) ---
resource "tls_private_key" "demo" {
  algorithm = "ED25519"
}

resource "aws_key_pair" "demo" {
  key_name   = "${var.name}-key"
  public_key = tls_private_key.demo.public_key_openssh
}

# --- instance profile so Session Manager works, no static keys ---
data "aws_iam_policy_document" "ec2_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ec2" {
  name               = "${var.name}-ec2-role"
  assume_role_policy = data.aws_iam_policy_document.ec2_assume.json
}

resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.ec2.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "ec2" {
  name = "${var.name}-ec2-profile"
  role = aws_iam_role.ec2.name
}

# --- the instance ---
resource "aws_instance" "web" {
  ami                         = data.aws_ami.al2023.id
  instance_type               = var.instance_type
  subnet_id                   = aws_subnet.public.id
  vpc_security_group_ids      = [aws_security_group.web.id]
  key_name                    = aws_key_pair.demo.key_name
  iam_instance_profile        = aws_iam_instance_profile.ec2.name
  associate_public_ip_address = true

  metadata_options {
    http_endpoint               = "enabled"
    http_tokens                 = "required"      # IMDSv2 only
    http_put_response_hop_limit = 1
    instance_metadata_tags      = "enabled"
  }

  root_block_device {
    volume_type           = "gp3"
    volume_size           = 20
    encrypted             = true
    delete_on_termination = true
    tags                  = { Name = "${var.name}-root" }
  }

  user_data = <<-EOF
    #!/bin/bash
    set -euxo pipefail
    dnf -y update
    dnf -y install nginx
    cat > /usr/share/nginx/html/index.html <<'HTML'
    <!doctype html>
    <h1>Hello from Terraform on EC2</h1>
    <p>Installed by user_data at first boot. AMI: ${data.aws_ami.al2023.id}</p>
    HTML
    systemctl enable --now nginx
  EOF

  user_data_replace_on_change = true

  tags = { Name = "${var.name}-web" }
}

# --- a separate, reattachable encrypted data disk ---
resource "aws_ebs_volume" "data" {
  availability_zone = aws_subnet.public.availability_zone   # MUST match the instance AZ
  size              = 50
  type              = "gp3"
  encrypted         = true
  tags              = { Name = "${var.name}-data" }
}

resource "aws_volume_attachment" "data" {
  device_name = "/dev/sdf"
  volume_id   = aws_ebs_volume.data.id
  instance_id = aws_instance.web.id
}

# --- a stable public address ---
resource "aws_eip" "web" {
  domain     = "vpc"
  instance   = aws_instance.web.id
  depends_on = [aws_internet_gateway.this]
  tags       = { Name = "${var.name}-eip" }
}

outputs.tf:

output "elastic_ip" {
  description = "Stable public IP of the web box"
  value       = aws_eip.web.public_ip
}

output "instance_id" {
  description = "Instance id (use it with SSM)"
  value       = aws_instance.web.id
}

output "ssm_command" {
  description = "Open a shell with no SSH, no key"
  value       = "aws ssm start-session --target ${aws_instance.web.id}"
}

output "ssh_command" {
  description = "SSH path (save the key first — see note)"
  value       = "ssh -i ${var.name}-key.pem ec2-user@${aws_eip.web.public_ip}"
}

output "private_key_pem" {
  description = "Private key if you insist on SSH (also lives in state)"
  value       = tls_private_key.demo.private_key_pem
  sensitive   = true
}

Step 2 — init. Download the providers:

terraform init
Initializing provider plugins...
- Installing hashicorp/aws v5.62.0...
- Installing hashicorp/tls v4.0.5...
Terraform has been successfully initialized!

Step 3 — plan. Pass your IP so the SSH rule is scoped to you:

terraform plan -var "my_ip_cidr=$(curl -s https://checkip.amazonaws.com)/32"
  # aws_instance.web will be created
  + resource "aws_instance" "web" {
      + ami                          = "ami-0abc123def4567890"
      + instance_type                = "t3.micro"
      + associate_public_ip_address  = true
      + user_data_replace_on_change  = true
      + ...
    }
  # (~15 more: vpc, igw, subnet, route table + assoc, sg + 4 rules,
  #  key pair, iam role/attachment/profile, ebs volume + attachment, eip)

Plan: 17 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + elastic_ip  = (known after apply)
  + instance_id = (known after apply)

Read the plan. 17 to add, 0 to change, 0 to destroy is what a first apply should say. Note the ami shows a concrete id — that’s the data source resolving the latest AL2023 for you. If you ever see destroy on something you didn’t expect, stop.

Step 4 — apply. Build it (-auto-approve skips the prompt; drop it to review):

terraform apply -var "my_ip_cidr=$(curl -s https://checkip.amazonaws.com)/32" -auto-approve
aws_instance.web: Creating...
aws_instance.web: Creation complete after 22s [id=i-0abc123def4567890]
aws_volume_attachment.data: Creation complete after 9s
aws_eip.web: Creation complete after 2s

Apply complete! Resources: 17 added, 0 changed, 0 destroyed.

Outputs:
elastic_ip  = "13.234.55.66"
instance_id = "i-0abc123def4567890"
ssm_command = "aws ssm start-session --target i-0abc123def4567890"

Step 5 — verify. Give user-data a minute to install nginx on first boot, then curl the Elastic IP:

IP=$(terraform output -raw elastic_ip)
curl "http://$IP"
<!doctype html>
<h1>Hello from Terraform on EC2</h1>
<p>Installed by user_data at first boot. AMI: ami-0abc123def4567890</p>

Then open a shell without SSH using the instance profile you attached — no key, no open port 22:

aws ssm start-session --target "$(terraform output -raw instance_id)"
# on the box:
sudo systemctl is-active nginx      # -> active
lsblk                               # the 50 GiB data disk (nvme1n1 / xvdf), unformatted
cloud-init status --wait            # -> status: done
cat /var/log/cloud-init-output.log  # the console of your user-data script

If you prefer SSH, save the generated key first: terraform output -raw private_key_pem > kv-demo-key.pem && chmod 400 kv-demo-key.pem, then use the ssh_command output. But SSM needed no key and left a CloudTrail record.

Step 6 — destroy. ⚠️ Do this when you’re done — the EIP and EBS bill by the hour even though the instance is free-tier. One command removes everything in reverse dependency order:

terraform destroy -var "my_ip_cidr=$(curl -s https://checkip.amazonaws.com)/32" -auto-approve
Plan: 0 to add, 0 to change, 17 to destroy.
...
Destroy complete! Resources: 17 destroyed.

Confirm nothing lingers — an orphaned EBS volume or an unassociated EIP is the classic surprise bill:

aws ec2 describe-volumes  --filters Name=tag:project,Values=kv-ec2-demo --query 'Volumes[].VolumeId'
aws ec2 describe-addresses --filters Name=tag:project,Values=kv-ec2-demo --query 'Addresses[].PublicIp'

Variables, outputs and making it reusable

The demo hardcodes one box. Real usage wants many from the same definition. Two patterns get you there.

for_each over a map turns the single instance into a fleet keyed by name, each with its own type — no copy-paste:

variable "servers" {
  type = map(object({
    instance_type = string
  }))
  default = {
    web-1 = { instance_type = "t3.micro" }
    web-2 = { instance_type = "t3.micro" }
    app-1 = { instance_type = "t3.small" }
  }
}

resource "aws_instance" "fleet" {
  for_each      = var.servers
  ami           = data.aws_ami.al2023.id
  instance_type = each.value.instance_type
  subnet_id     = aws_subnet.public.id

  vpc_security_group_ids = [aws_security_group.web.id]
  iam_instance_profile   = aws_iam_instance_profile.ec2.name

  metadata_options {
    http_tokens = "required"
  }
  root_block_device {
    volume_type = "gp3"
    encrypted   = true
  }
  tags = { Name = "${var.name}-${each.key}" }
}

Adding db-1 is a one-line change to the map; Terraform plans exactly one new instance. Reference an individual member with aws_instance.fleet["web-1"].private_ip.

Wrap it in a module when the whole pattern (SG + rules + instance + profile + volume + EIP) repeats across projects. Expose instance_type, ami_id, subnet_id, vpc_id, ingress_rules and key_name; output the instance id, private IP and EIP. Then decide build-vs-borrow:

Option Source Use when
Roll your own module ./modules/ec2-web You need a specific, opinionated shape; full control
Community registry module terraform-aws-modules/ec2-instance/aws You want a battle-tested, feature-complete instance fast
Golden AMI + thin module Packer image + a tiny wrapper Immutable infra; boot time and drift matter

The official terraform-aws-modules/ec2-instance/aws wraps exactly this graph with sane defaults, IMDSv2 on by default, and clean variable surfaces — pin its version so a registry update never surprises a plan, and roll your own only when your standards diverge.

Common mistakes and troubleshooting

EC2 fails in a small number of well-worn ways. Scan this when apply errors or the box is up but wrong:

Symptom Likely cause Fix
SSH times out / refused SG has no 22 rule, wrong source IP, or the box is in a private subnet with no route Add the 22 ingress from my_ip_cidr; confirm the subnet has a 0.0.0.0/0 route to the IGW and a public IP/EIP
SSH: Permission denied (publickey) Wrong user for the AMI, or wrong key AL2023/RHEL = ec2-user, Ubuntu = ubuntu; use the private key matching key_name
curl hangs / connection refused on 80 user-data still running, nginx not installed, or no 80 rule Wait ~1 min; check /var/log/cloud-init-output.log; confirm the HTTP ingress rule
user-data didn’t run / edit ignored It only runs on first boot; user_data_replace_on_change is false Set user_data_replace_on_change = true so an edit recreates the box
InvalidAMIID.NotFound Hardcoded AMI id from another region, or a rotated/deprecated id Use data.aws_ami with owners + filter + most_recent
Instance launches then immediately stops AMI architecture ≠ instance type (x86 AMI on Graviton, or vice-versa) Match -x86_64 AMI to an x86 type, -arm64 to a *g type
SG rules flap on every apply Inline ingress/egress mixed with standalone rule resources Pick ONE model; an SG with standalone rules must have no inline blocks
No outbound / dnf hangs / SSM offline Terraform removed AWS’s default egress and none was re-added Add an explicit aws_vpc_security_group_egress_rule (ip_protocol = "-1")
VolumeInUse / disk not visible in Linux AZ mismatch, or you attached but didn’t format/mount Match aws_ebs_volume.availability_zone to the subnet AZ; mkfs + mount the device
AddressLimitExceeded More than 5 EIPs in the region (default quota) Release unused EIPs or request a quota increase
InsufficientInstanceCapacity AWS is out of that type in that AZ Try another AZ, a different size, or an older generation
IMDSv2 SDK calls fail from a container http_put_response_hop_limit = 1 blocks the extra hop Set the hop limit to 2 when a container must reach IMDS
UnauthorizedOperation on apply The Terraform principal lacks ec2:*/iam:PassRole Grant the runner the needed actions; iam:PassRole is required to attach the profile

Four traps deserve prose because they eat afternoons. Ordering is inferred, not declared — you never write “create the SG before the instance”; the instance’s vpc_security_group_ids = [aws_security_group.web.id] is the edge. If you reach for depends_on between an instance and its SG, you probably hardcoded an id where a reference belongs; the one legitimate depends_on here is the EIP → IGW race. user_data is first-boot-only and, by default, silently immutable — teams are stunned when a “quick script fix” does nothing on the running box; set user_data_replace_on_change = true and accept that the edit means a replacement, or move mutable config to SSM. The default-egress removal locks new SGs out of the internet — always add an explicit egress rule. And iam:PassRole is the permission everyone forgets: to attach an instance profile, the Terraform principal itself needs iam:PassRole on that role, or apply fails with UnauthorizedOperation even though the role is perfectly defined.

Cost, cleanup and production notes

An instance is billed while running (stopped instances stop compute charges), but its EBS volumes and any Elastic IP bill regardless. Rough monthly cost if you forget to destroy this demo (ap-south-1, on-demand, indicative — a t3.micro is free for the first 12 months):

Resource Rate driver ~Cost if left a month
t3.micro (beyond free tier) Compute hours (running) ~₹750 (~$9)
gp3 20 GiB root volume Provisioned size ~₹150 (~$1.8)
gp3 50 GiB data volume Provisioned size ~₹380 (~$4.5)
Elastic IP Hourly, whether attached or not ~₹300 (~$3.6)
Public IPv4 (auto-assigned) Hourly since Feb 2024 ~₹300 (~$3.6) if you keep one
Total ~₹1,600–1,900 / month if abandoned

Two cost levers: stop the instance when idle (aws ec2 stop-instances halts compute billing — but EBS and the EIP still bill), and destroy when truly done (terraform destroy removes everything). Note that an in-OS shutdown stops the instance and stops compute billing, unlike some clouds, but the disks and IP persist.

Production hardening, five notes:

Practice Why How
Remote, locked state An EC2 graph in local terraform.tfstate is a disaster S3 backend + DynamoDB (or S3 native) lock — see below
No SSH, no public IP on servers Attack surface SSM Session Manager + private subnets + an ALB/NAT
IMDSv2 + least-privilege profile Stops credential theft via metadata http_tokens = "required"; scope the role’s policies
Encrypt every volume Data-at-rest compliance encrypted = true (+ a CMK where key control matters)
Pin AMIs for prod fleets most_recent can shift under you and replace instances Resolve once, pass an explicit ami_id, or use a golden AMI

The brief requires you to see the remote backend at least once — for AWS that is an S3 bucket (state) with a lock. Historically the lock lived in a DynamoDB table; modern Terraform can use S3-native locking instead. Create the bucket (and table, if used) once, then:

terraform {
  backend "s3" {
    bucket         = "kv-tfstate-ap-south-1"   # a bucket you created first
    key            = "ec2-demo/terraform.tfstate"
    region         = "ap-south-1"
    encrypt        = true
    dynamodb_table = "kv-tflock"                # or: use_lockfile = true for S3-native lock
  }
}

Cheat-sheet

The resources, in dependency order:

Resource One-line role
data.aws_ami Resolve the latest image (owners + filter + most_recent)
aws_security_group The stateful firewall shell (no inline rules if using standalone)
aws_vpc_security_group_ingress_rule / _egress_rule The recommended per-rule model (one CIDR each)
tls_private_key + aws_key_pair An SSH key pair (private key lands in state)
aws_iam_role + aws_iam_instance_profile The role the box assumes (for SSM / AWS APIs)
aws_instance The VM (AMI, type, subnet, SG, profile, user_data, IMDSv2)
aws_ebs_volume + aws_volume_attachment A reattachable encrypted data disk (AZ-matched)
aws_eip A stable public IP (domain = "vpc")

The arguments you set every time:

Need Argument Value
Image ami data.aws_ami.al2023.id (never a literal)
Size instance_type t3.micro (free) / m6i.large (real) / *g = Graviton
Place subnet_id aws_subnet.public.id (chooses the AZ)
Firewall vpc_security_group_ids [aws_security_group.web.id]
Access key_name + iam_instance_profile key pair + SSM profile (prefer SSM)
Boot user_data + user_data_replace_on_change heredoc bash; true
Harden metadata_options http_tokens = "required", hop limit 1
Root disk root_block_device gp3, encrypted = true
Data disk aws_ebs_volume + aws_volume_attachment AZ = subnet AZ, gp3, encrypted
Address aws_eip domain = "vpc", depends_on IGW

Commands:

terraform init
terraform plan  -var "my_ip_cidr=$(curl -s https://checkip.amazonaws.com)/32"
terraform apply -var "my_ip_cidr=$(curl -s https://checkip.amazonaws.com)/32"
terraform output -raw elastic_ip
terraform destroy -var "my_ip_cidr=$(curl -s https://checkip.amazonaws.com)/32"

aws ssm start-session --target <instance-id>          # shell, no SSH
aws ec2 describe-images --owners amazon \
  --filters "Name=name,Values=al2023-ami-2023.*-x86_64" \
  --query 'reverse(sort_by(Images,&CreationDate))[0].ImageId' --output text   # latest AL2023

Interview and exam questions

1. Why does terraform apply for “one EC2 instance” create a dozen resources? Because a reachable instance is a graph: a subnet, an internet gateway and route for connectivity, a Security Group plus its rules, an AMI lookup, a key pair or instance profile for access, EBS volumes, and an Elastic IP. Each is its own resource, and the references between them build the dependency order.

2. Why should you never hardcode an AMI id, and what do you use instead? AMI ids are region-specific and change every time AWS re-publishes the patched base image, so a literal id breaks in another region (InvalidAMIID.NotFound) or leaves you unpatched. Use data.aws_ami with owners, filter blocks and most_recent = true (or the AL2023 SSM public parameter) to resolve the latest at plan time.

3. What are the three ways to write Security Group rules in Terraform, and what’s the rule about mixing them? Inline ingress/egress blocks on aws_security_group; the older standalone aws_security_group_rule; and the newer aws_vpc_security_group_ingress_rule/_egress_rule (one CIDR per rule, recommended). You must use exactly one model per Security Group — mixing them makes each apply delete the other’s rules.

4. Explain the default-egress trap. AWS adds a default allow-all egress rule to a new SG, but Terraform’s aws_security_group manages egress and, if you declare none, removes that default to match your empty config — leaving the box with no outbound. Always add an explicit egress rule.

5. What does IMDSv2 protect against and how do you enforce it? IMDSv1 answered any GET to 169.254.169.254, so an SSRF or a container reaching the link-local address could read the instance role’s temporary credentials. IMDSv2 requires a session token (a PUT) and a hop limit. Enforce with metadata_options { http_tokens = "required"; http_put_response_hop_limit = 1 }.

6. Why prefer SSM Session Manager over SSH, and what does the instance need for it? SSM gives an audited shell with no inbound port, no key pair and no bastion — it works in private subnets. The instance needs an instance profile with the AmazonSSMManagedInstanceCore policy and egress to the SSM endpoints; you connect with aws ssm start-session --target <id>.

7. What’s the difference between root_block_device, inline ebs_block_device, and a standalone aws_ebs_volume? root_block_device configures the OS disk (dies with the instance). Inline ebs_block_device adds extra disks that also die with the instance. A standalone aws_ebs_volume + aws_volume_attachment is an independent disk that survives the instance, can be resized or reattached — the right choice for real data. Its AZ must match the instance.

8. Why gp3 over gp2? gp3 decouples IOPS (3,000 baseline) and throughput (125 MiB/s baseline) from volume size — gp2 tied performance to size — and costs about 20% less. It should be the default for boot and most workloads; reserve io2 for latency-critical databases.

9. (Terraform Associate style) A colleague sets user_data on a running instance, applies, and nothing changes on the box. Why, and what’s the fix? user_data runs only on first boot, and user_data_replace_on_change defaults to false, so Terraform ignores the edit. Set user_data_replace_on_change = true so an edited script forces a replacement and actually re-runs — or move mutable config to SSM.

10. (Terraform Associate style) terraform apply fails with UnauthorizedOperation when attaching the instance profile, though the role is defined. What’s missing? The Terraform principal needs iam:PassRole on that role to pass it to EC2 — a permission separate from creating the role. Grant iam:PassRole (ideally scoped to the specific role ARN).

11. How does an Elastic IP differ from an auto-assigned public IP, and what’s the cost catch? An auto-assigned public IP changes on every stop/start; an aws_eip is a static, account-owned address you can point DNS at. The catch: EIPs default to 5 per region, and since Feb 2024 every public IPv4 (attached or not) bills ~$0.005/hour — an unattached EIP is a silent charge.

12. How does Terraform know to create the Security Group before the instance without depends_on? From the reference: vpc_security_group_ids = [aws_security_group.web.id] is a graph edge, so Terraform orders the SG first. Reference resource attributes instead of hardcoding ids and the dependency builds itself; depends_on is only for hidden dependencies Terraform can’t see (like the EIP → IGW race).

Key takeaways

TerraformawsEC2aws_instancesecurity-groupEBSgp3key-pairIMDSv2user-dataElastic-IPSSMIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments