Provisions a single, secure-by-default Amazon EC2 instance together with its extra EBS data volumes, their attachments, and an optional Elastic IP β an IMDSv2-enforced, fully-encrypted host from one module call. Built for the AWS provider v6.x.
- π One host, fully wired. Creates
aws_instanceplus everything directly coupled to it: extraaws_ebs_volumedata volumes, theiraws_volume_attachments, and an optionalaws_eip+aws_eip_association. - π IMDSv2 enforced by default.
metadata_optionsdefaults to session tokens required and a hop limit of 1, so the metadata service can't be reached from a container or proxied off-box β relax only with a documented exception. - π‘οΈ Encryption is always on. The root volume is
encrypted = true(no opt-out exposed) and every extra EBS volume defaults to encrypted; supply a customer-managed CMK viakms_key_arnfor auditable, revocable key access on PII-bearing hosts. - π« Private by default.
associate_public_ip_address = falseβ hosts stay private and are reached via SSM Session Manager (the preferred keyless path) or an explicit Elastic IP. - π§± Consumes, doesn't create. AMI, subnet, security groups, key pair, instance profile, CMK, placement group, and capacity reservation are all referenced from sibling modules β the blast radius stays at one instance and its volumes.
- π Stable child collections. Extra volumes use
for_eachovermap(object)keyed by a caller string (nevercount), so reorders don't churn the plan. - π Region-inherited. No
regionvariable β the caller's provider sets the region.
π‘ Why it matters: A pet host is where unencrypted disks, IMDSv1, and public IPs quietly creep in. A single secure-by-default module makes the hardened posture β IMDSv2, encrypted EBS, private networking, consistent governance tags β the default across the estate, not a per-team checklist.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- β Star this repository to help others discover this Terraform module.
- π€ Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- β Buy me a coffee: buymeacoffee.com/microsoftexpert
Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!
tf-mod-aws-ec2-instance is a compute consumer β it wires into networking, identity, encryption, and image foundations, and emits an instance id/arn consumed by monitoring, DNS, and load-balancer target registration.
flowchart LR
vpc["tf-mod-aws-vpc<br/>subnet_id"]
sg["tf-mod-aws-security-group<br/>security_group_ids"]
ami["tf-mod-aws-ami<br/>ami id"]
kms["tf-mod-aws-kms<br/>EBS CMK"]
role["tf-mod-aws-iam-role<br/>instance profile"]
kp["tf-mod-aws-key-pair<br/>key_name"]
pg["tf-mod-aws-placement-group"]
ec2["tf-mod-aws-ec2-instance"]
lb["tf-mod-aws-lb<br/>target registration"]
r53["tf-mod-aws-route53-zone<br/>A/AAAA records"]
vpc -->|"subnet_id"| ec2
sg -->|"security_group_ids"| ec2
ami -->|"ami"| ec2
kms -->|"kms_key_arn"| ec2
role -->|"iam_instance_profile"| ec2
kp -->|"key_name"| ec2
pg -->|"placement_group"| ec2
ec2 -->|"id / private_ip"| lb
ec2 -->|"private_ip / eip_public_ip"| r53
style ec2 fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
flowchart TD
subgraph mod["tf-mod-aws-ec2-instance"]
inst["aws_instance.this<br/>(keystone)<br/>IMDSv2 + encrypted root + metadata/cpu/spot blocks"]
vol["aws_ebs_volume.this<br/>for_each ebs_volumes (encrypted)"]
att["aws_volume_attachment.this<br/>for_each ebs_volumes"]
eip["aws_eip.this<br/>for_each (var.eip != null)"]
assoc["aws_eip_association.this<br/>for_each (var.eip != null)"]
end
inst --> vol
vol --> att
att --> inst
inst --> assoc
eip --> assoc
style inst fill:#FF9900,color:#fff,stroke:#cc7a00,stroke-width:2px
style vol stroke-dasharray: 5 5
style att stroke-dasharray: 5 5
style eip stroke-dasharray: 5 5
style assoc stroke-dasharray: 5 5
| Resource | Count | Created when |
|---|---|---|
aws_instance.this |
1 | always (keystone) |
aws_ebs_volume.this |
0..N | one per ebs_volumes entry |
aws_volume_attachment.this |
0..N | one per ebs_volumes entry |
aws_eip.this |
0..1 | when var.eip != null |
aws_eip_association.this |
0..1 | when var.eip != null |
| Requirement | Version |
|---|---|
| Terraform | >= 1.12.0 |
hashicorp/aws |
>= 6.0, < 7.0 |
The module declares only a required_providers block (providers.tf) and inherits the configured provider. There is no provider {} block and no credential variable β credentials resolve through the standard AWS chain at the root/pipeline level (env vars β SSO/shared credentials β assume_role β instance profile / IRSA β OIDC web identity).
Least-privilege actions the Terraform execution identity needs to manage this module.
| Action | Required for | Notes |
|---|---|---|
ec2:RunInstances, ec2:TerminateInstances |
Instance lifecycle | Core create/destroy |
ec2:DescribeInstances, ec2:DescribeInstanceAttribute, ec2:DescribeInstanceStatus, ec2:DescribeInstanceTypes |
Read / refresh | Plan + state |
ec2:ModifyInstanceAttribute, ec2:ModifyInstanceMetadataOptions |
Updates, IMDSv2 enforcement | In-place changes |
ec2:StartInstances, ec2:StopInstances |
Attribute changes requiring a stop/start | e.g. user_data, instance_type, volume detach |
ec2:CreateVolume, ec2:DeleteVolume, ec2:DescribeVolumes |
Extra EBS data volumes | Only with ebs_volumes |
ec2:AttachVolume, ec2:DetachVolume |
Volume attachments | Only with ebs_volumes |
ec2:AllocateAddress, ec2:ReleaseAddress, ec2:AssociateAddress, ec2:DisassociateAddress, ec2:DescribeAddresses |
Optional Elastic IP | Only with var.eip |
ec2:CreateTags, ec2:DeleteTags, ec2:DescribeTags |
Tagging instance + volumes + EIP | On every taggable resource |
iam:PassRole |
Attaching iam_instance_profile |
Scope to the role ARN, condition iam:PassedToService = ec2.amazonaws.com |
kms:DescribeKey, kms:CreateGrant, kms:GenerateDataKeyWithoutPlaintext |
CMK-encrypted EBS volumes | Only when kms_key_arn (or a per-volume key) is set; granted on the key |
β οΈ iam:PassRoleis the one that bites. Attaching an instance profile fails withnot authorized to perform: iam:PassRoleunless the Terraform identity may pass that specific role toec2.amazonaws.com. Scope it to the role ARN and add theiam:PassedToServicecondition.
π Scope
ec2:*actions to aaws:ResourceTag/*condition (e.g. team/cost-center tag) so a pipeline can only touch its own hosts.
- No service-linked role is required for a plain EC2 instance.
iam:PassRole(conditional). Attachingiam_instance_profilerequires the Terraform identity to be permitted to pass that role toec2.amazonaws.com(see above). The value is the instance-profile name, not the role name or ARN.- CMK (optional). For customer-managed EBS encryption, the KMS key (
kms_key_arn) must exist and its key policy must grant the EC2/EBS servicekms:CreateGrantandkms:GenerateDataKeyWithoutPlaintextβ otherwise the instance launch (or volume create) fails with a key-permission error. Wirekms_key_arnfromtf-mod-aws-kms. - Networking.
subnet_idandsecurity_group_idsmust exist in the same VPC. The instance's Availability Zone is derived from the subnet and reused for the extra EBS volumes (EBS volumes must live in the same AZ as the instance they attach to). - AMI. The
amimust be available in the target Region (AMIs are Region-scoped). Wire fromtf-mod-aws-amior supply a public/marketplace id. - Hibernation (optional). Requires a supported instance type and an encrypted root volume (this module encrypts the root volume by default, so that prerequisite is already met).
- Quotas (per Amazon EC2 service quotas, adjustable via Service Quotas):
- Running On-Demand instances β a vCPU-based quota per instance family per Region (e.g. Running On-Demand Standard instances). Spot has a separate vCPU quota.
- 5 Elastic IPs per Region (default, raisable) β relevant only when
var.eipis set. - EBS volume storage and IOPS quotas per volume type per Region.
- Region. No
regionvariable β the resource inherits the caller's provider region. No us-east-1 global-service constraint applies to EC2.
tf-mod-aws-ec2-instance/
βββ providers.tf # required_providers (aws >= 6.0, < 7.0); no provider block
βββ variables.tf # name β ami/instance_type/subnet β networking β identity β placement β user_data β IMDS β encryption β ebs_volumes β eip β optional blocks β tags β timeouts
βββ main.tf # aws_instance.this + ebs_volume / volume_attachment / eip / eip_association
βββ outputs.tf # id + arn + networking/DNS + storage + eip + tags_all
βββ README.md # this file
βββ SCOPE.md # in/out-of-scope, IAM permissions, prerequisites, gotchas
Smallest working call β a private, IMDSv2-enforced, encrypted host in a VPC private subnet:
module "app_host" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-app01"
ami = data.aws_ami.al2023.id # or module.app_ami.id
instance_type = "t3.micro"
subnet_id = module.vpc.private_subnet_ids[0] # from tf-mod-aws-vpc
security_group_ids = [module.app_sg.id] # from tf-mod-aws-security-group
iam_instance_profile = module.app_role.instance_profile_name # from tf-mod-aws-iam-role
# Root volume encrypted (always on); private (no public IP); IMDSv2 required β all by default.
tags = {
Environment = "prod"
CostCenter = "1234"
}
}| Input | Type | Source module |
|---|---|---|
ami |
string (AMI id) |
tf-mod-aws-ami (or public/marketplace) |
subnet_id |
string (subnet id) |
tf-mod-aws-vpc |
security_group_ids |
list(string) (SG ids) |
tf-mod-aws-security-group |
iam_instance_profile |
string (instance-profile name) |
tf-mod-aws-iam-role |
kms_key_arn |
string (KMS key ARN) |
tf-mod-aws-kms |
key_name |
string (key-pair name) |
tf-mod-aws-key-pair |
placement_group |
string (name) |
tf-mod-aws-placement-group |
capacity_reservation_id |
string (cr-...) |
tf-mod-aws-capacity-reservation |
| Output | Description | Consumed by |
|---|---|---|
id |
Instance id (i-...) |
ASG/LB target registration, monitoring, SSM targeting |
arn |
Instance ARN β the cross-resource reference type | IAM policies, EventBridge rules, AWS Config |
private_ip |
Primary private IPv4 | DNS records, app config |
public_ip |
EIP address if allocated, else auto-assigned public IP | DNS, allow-lists |
private_dns / public_dns |
Instance DNS names | service discovery |
primary_network_interface_id |
Primary ENI id | additional-ENI / EIP wiring |
ipv6_addresses |
Assigned IPv6 addresses, if any | dual-stack DNS |
availability_zone |
AZ the instance + volumes run in | capacity planning |
instance_state |
Current state (running/stopped) | verification |
root_block_device_volume_id |
Root EBS volume id | snapshots |
ebs_volume_ids / ebs_volume_arns |
Maps of extra data-volume id / ARN by key | snapshots, monitoring |
eip_id / eip_public_ip |
Allocated EIP id / address (if created) | Route 53, allow-lists |
password_data |
Encrypted Windows admin password β sensitive (only with get_password_data) |
bootstrap automation |
tags_all |
All tags incl. provider default_tags |
governance / audit |
1 Β· Minimal private host (encrypted, IMDSv2, no public IP)
module "host" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-host01"
ami = "ami-0abcdef1234567890"
instance_type = "t3.small"
subnet_id = module.vpc.private_subnet_ids[0]
# Defaults: encrypted root, IMDSv2 required, no public IP, VPC default SG fallback (override it!)
}2 Β· Tags (merge with provider default_tags)
module "tagged_host" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-tagged"
ami = data.aws_ami.al2023.id
instance_type = "t3.micro"
subnet_id = module.vpc.private_subnet_ids[0]
# These land on the instance, root volume, every extra EBS volume, and the EIP.
# They merge with provider default_tags; resource tags win on key conflict.
tags = {
Environment = "prod"
DataClass = "internal"
Owner = "platform"
}
}
# module.tagged_host.tags_all reflects the merged set (resource tags over default_tags).3 Β· Security groups + instance profile wired from siblings
module "app_host" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-app"
ami = data.aws_ami.al2023.id
instance_type = "m6i.large"
subnet_id = module.vpc.private_subnet_ids[0]
security_group_ids = [module.app_sg.id, module.shared_sg.id] # tf-mod-aws-security-group
iam_instance_profile = module.app_role.instance_profile_name # tf-mod-aws-iam-role
# iam:PassRole on module.app_role's role ARN is required by the TF identity.
}4 Β· Customer-managed-KMS EBS encryption (root + data volumes)
module "app_kms" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-kms?ref=v1.0.0"
alias = "casey/app-ebs"
# Key policy must allow the EC2/EBS service kms:CreateGrant + kms:GenerateDataKeyWithoutPlaintext.
}
module "app_host" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-npi-host"
ami = data.aws_ami.al2023.id
instance_type = "m6i.large"
subnet_id = module.vpc.private_subnet_ids[0]
kms_key_arn = module.app_kms.arn # encrypts the root volume + any data volume without its own key
}5 Β· Extra EBS data volumes (encrypted by default)
module "db_host" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-db"
ami = data.aws_ami.al2023.id
instance_type = "r6i.xlarge"
subnet_id = module.vpc.private_subnet_ids[0]
kms_key_arn = module.app_kms.arn
ebs_volumes = {
data = {
device_name = "/dev/sdf"
size = 200
type = "gp3"
throughput = 250
iops = 4000
}
logs = {
device_name = "/dev/sdg"
size = 50
final_snapshot = true # snapshot on destroy
}
}
}
# module.db_host.ebs_volume_ids["data"] / ["logs"] feed snapshots and monitoring.6 Β· Sized / tuned root volume
root_block_device = {
volume_type = "gp3"
volume_size = 100
iops = 6000
throughput = 300
# encrypted is always true here β it cannot be turned off.
tags = { Backup = "daily" }
}7 Β· Optional Elastic IP (genuinely public host)
module "edge_host" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-edge"
ami = data.aws_ami.al2023.id
instance_type = "t3.small"
subnet_id = module.vpc.public_subnet_ids[0]
security_group_ids = [module.edge_sg.id]
# Allocate + associate a stable EIP. {} = Amazon-pool address with defaults.
eip = {
tags = { Purpose = "ingress" }
}
}
# module.edge_host.eip_public_ip is the stable address for Route 53 / allow-lists.8 Β· User data (cloud-init), in-place stop/start vs replace
module "bootstrapped" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-boot"
ami = data.aws_ami.al2023.id
instance_type = "t3.micro"
subnet_id = module.vpc.private_subnet_ids[0]
user_data = <<-EOT
#!/bin/bash
dnf -y update
systemctl enable --now amazon-ssm-agent
EOT
# true = a user_data change REPLACES the instance (immutable host); false = stop/start in place.
user_data_replace_on_change = true
}
# Never put secrets in user_data β it's readable from the instance metadata service.9 Β· Termination + stop protection (long-lived pet)
module "pet" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-pet"
ami = data.aws_ami.al2023.id
instance_type = "m6i.large"
subnet_id = module.vpc.private_subnet_ids[0]
disable_api_termination = true # guard against accidental terminate (clear before destroy)
disable_api_stop = true # guard against accidental stop
monitoring = true # detailed 1-minute CloudWatch metrics
}10 Β· Burstable T-family with unlimited credits
module "burst" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-burst"
ami = data.aws_ami.al2023.id
instance_type = "t3.medium"
subnet_id = module.vpc.private_subnet_ids[0]
credit_specification = { cpu_credits = "unlimited" }
}11 Β· Spot instance
module "spot_worker" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-spot"
ami = data.aws_ami.al2023.id
instance_type = "m6i.large"
subnet_id = module.vpc.private_subnet_ids[0]
instance_market_options = {
market_type = "spot"
spot_options = {
spot_instance_type = "one-time"
instance_interruption_behavior = "terminate"
max_price = "0.05"
}
}
}12 Β· CPU options (disable SMT) + Nitro Enclaves
cpu_options = {
core_count = 4
threads_per_core = 1 # disable hyperthreading
}
enclave_options = { enabled = true }13 Β· Capacity reservation + placement group
module "reserved" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-reserved"
ami = data.aws_ami.al2023.id
instance_type = "c6i.large"
subnet_id = module.vpc.private_subnet_ids[0]
capacity_reservation_id = module.cr.id # tf-mod-aws-capacity-reservation
placement_group = module.pg.name # tf-mod-aws-placement-group
}14 Β· Secure-default opt-out β relax IMDS (documented exception)
# β οΈ EXCEPTION β only with an approved security exception on file.
# Raising the hop limit lets containers reach IMDS; allowing IMDSv1 weakens SSRF protection.
module "legacy_host" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-legacy"
ami = data.aws_ami.al2023.id
instance_type = "t3.micro"
subnet_id = module.vpc.private_subnet_ids[0]
metadata_options = {
http_tokens = "optional" # allows IMDSv1 β discouraged
http_put_response_hop_limit = 2 # reachable from one container hop
}
}15 Β· End-to-end composition β VPC + KMS + SG + Role + AMI + EC2
module "vpc" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-vpc?ref=v1.0.0"
#... emits private_subnet_ids across multiple AZs
}
module "app_kms" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-kms?ref=v1.0.0"
alias = "casey/app-ebs"
}
module "app_sg" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-security-group?ref=v1.0.0"
vpc_id = module.vpc.id
#... least-privilege ingress/egress
}
module "app_role" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-iam-role?ref=v1.0.0"
name = "casey-app"
assume_role_policy = data.aws_iam_policy_document.ec2_trust.json # ec2.amazonaws.com
instance_profile = {} # emits instance_profile_name
}
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
# This module β the host
module "app_host" {
source = "git::https://github.com/microsoftexpert/tf-mod-aws-ec2-instance?ref=v1.0.0"
name = "casey-app01"
ami = data.aws_ami.al2023.id
instance_type = "m6i.large"
subnet_id = module.vpc.private_subnet_ids[0]
security_group_ids = [module.app_sg.id]
iam_instance_profile = module.app_role.instance_profile_name
kms_key_arn = module.app_kms.arn
ebs_volumes = {
data = { device_name = "/dev/sdf", size = 200, type = "gp3" }
}
monitoring = true
tags = { Environment = "prod", DataClass = "confidential" }
}| Name | Type | Default | Description |
|---|---|---|---|
name |
string |
β required | Logical name; seeds the Name tag on the instance, volumes, and EIP. |
ami |
string |
β required | AMI id. FORCE-NEW. Region-scoped. |
instance_type |
string |
β required | EC2 type (e.g. t3.micro, m6i.large). |
subnet_id |
string |
β required | VPC subnet. FORCE-NEW. Derives the AZ. |
security_group_ids |
list(string) |
[] |
SG ids β vpc_security_group_ids. Empty falls back to the VPC default SG. |
associate_public_ip_address |
bool |
false |
Public IPv4. FORCE-NEW. Private baseline. |
private_ip / secondary_private_ips |
string / list |
null |
Static / secondary private IPs. |
source_dest_check |
bool |
true |
Disable only for NAT/router appliances. |
ipv6_address_count / ipv6_addresses / enable_primary_ipv6 |
number / list / bool |
null |
IPv6 assignment. |
key_name |
string |
null |
SSH key pair. FORCE-NEW. Prefer SSM. |
iam_instance_profile |
string |
null |
Instance-profile name (needs iam:PassRole). |
availability_zone / placement_group / placement_partition_number / tenancy / host_id / host_resource_group_arn |
various | null |
Placement controls (several FORCE-NEW). |
capacity_reservation_id |
string |
null |
Target a reservation (cr-...). |
user_data / user_data_base64 |
string |
null |
Cloud-init payload (mutually exclusive). |
user_data_replace_on_change |
bool |
false |
true = replace instead of stop/start. |
monitoring |
bool |
false |
Detailed 1-minute CloudWatch metrics. |
ebs_optimized |
bool |
null |
Null = type default. |
disable_api_termination / disable_api_stop |
bool |
false |
Termination / stop protection. |
instance_initiated_shutdown_behavior |
string |
null |
stop / terminate. |
hibernation |
bool |
null |
Needs supported type + encrypted root (default). |
get_password_data |
bool |
false |
Windows admin password (sensitive output). |
metadata_options |
object({...}) |
IMDSv2 enforced | http_tokens=required, hop limit 1 by default. |
kms_key_arn |
string |
null |
CMK for EBS encryption (null = AWS-managed key). |
root_block_device |
object({...}) |
{} (gp3, encrypted) |
Root volume; encryption always on. |
ebs_volumes |
map(object({...})) |
{} |
Extra encrypted data volumes + attachments. |
eip |
object({...}) |
null |
Allocate + associate an Elastic IP. |
cpu_options / credit_specification / enclave_options / maintenance_options / private_dns_name_options / instance_market_options |
object({...}) |
null |
Optional config blocks. |
tags |
map(string) |
{} |
Tags for instance + volumes + EIP. |
timeouts |
object({ create, update, delete }) |
{} |
Operation timeouts. |
See variables.tf for full heredoc schemas, the ebs_volumes / metadata_options / spot object trees, and validation rules.
| Name | Description |
|---|---|
id |
Instance id (i-...). |
arn |
Instance ARN (cross-resource reference type). |
private_ip |
Primary private IPv4. |
public_ip |
EIP address if allocated, else auto-assigned public IP. |
private_dns / public_dns |
Instance DNS names. |
primary_network_interface_id |
Primary ENI id. |
ipv6_addresses |
Assigned IPv6 addresses, if any. |
availability_zone |
AZ of the instance + volumes. |
instance_state |
Current instance state. |
password_data |
Encrypted Windows admin password β sensitive. |
root_block_device_volume_id |
Root volume id. |
ebs_volume_ids / ebs_volume_arns |
Maps of extra data-volume id / ARN by key. |
eip_id / eip_public_ip |
Allocated EIP id / address (if created). |
tags_all |
All tags incl. provider default_tags. |
- ARN format:
arn:aws:ec2:<region>:<account-id>:instance/i-β¦. Theidis the instance id (i-β¦). The ARN is the cross-resource reference type (IAMResource, EventBridge rule patterns, AWS Config resource identity). EBS volume ARNs arearn:aws:ec2:<region>:<account-id>:volume/vol-β¦; EIPs are referenced by allocation id (eipalloc-β¦). - π·οΈ
tagsβtags_allβdefault_tags.var.tags(with a seededNamefromvar.name) flows to the instance, the root volume, every extraaws_ebs_volume, and the optionalaws_eip.tags_allis the computed merge of resource tags over providerdefault_tagsβ resource tags win on key conflict.default_tagsis the caller's provider-block concern and is never set inside the module. Only the instance'stags_allis surfaced as an output. - Force-new fields.
ami,subnet_id,availability_zone,placement_group,tenancy,key_name,associate_public_ip_address,private_ip, disablingenable_primary_ipv6, anduser_data/user_data_base64whenuser_data_replace_on_change = trueall replace the instance. Mostinstance_typeand other attribute changes apply in place via a stop/start. Replacing the instance generates a newid/arnand a fresh root volume. - Root vs extra volumes. The root volume lives in the
root_block_deviceblock ofaws_instanceand is removed with the instance (delete_on_termination = trueby default). Extra data volumes are independentaws_ebs_volume+aws_volume_attachmentresources that persist beyond the instance lifecycle unless explicitly removed. The root-volume CMK falls back module-key β AWS-managed; each data-volume CMK falls back per-volume key βvar.kms_key_arnβ AWS-managed. - EBS AZ coupling. Extra volumes are created in
aws_instance.this.availability_zoneβ they can only attach to an instance in the same AZ. Moving the instance's AZ (via subnet change) is a replacement. - Eventual consistency. A freshly run instance may briefly report
pending;private_dns/public_dnsandpublic_ippopulate oncerunning. An IAM instance profile attached the same second it's created can intermittently fail to attach (IAM propagation lag) β re-apply resolves it, or depend on the role module's outputs (this module does, via the passed profile name). - Destroy ordering. Terraform's graph handles the safe order: EIP association β EIP (disassociate before release β releasing an associated EIP fails); volume attachment β volume (detach before delete; the attachment defaults to stopping the instance first for a clean detach). The primary ENI is owned by the instance and releases with it; a public/Elastic IP must be disassociated before the ENI/instance tears down. Setting
disable_api_termination = trueblocksdestroyuntil cleared. - us-east-1 globals: N/A β EC2 is a regional service with no global-endpoint constraint.
Secure-by-default posture and every opt-out, explicitly:
| Posture | Default | Opt-out |
|---|---|---|
| IMDS | IMDSv2 required (http_tokens = "required", hop limit 1) |
relax metadata_options (discouraged; documented exception) |
| Root volume encryption | encrypted = true (CMK when kms_key_arn set, else AWS-managed) |
none β always on by design |
| Extra EBS encryption | encrypted = true per volume |
per-volume encrypted = false (discouraged) |
| Public networking | associate_public_ip_address = false (private; SSM access) |
set true, or attach an eip |
| Source/dest check | source_dest_check = true |
false (NAT/router/VPN appliances only) |
| Detailed monitoring | monitoring = false (basic 5-minute metrics) |
monitoring = true |
| Termination protection | disable_api_termination = false |
true (long-lived pets) |
| Data-volume safety on detach | stop_instance_before_detaching = true; final_snapshot = false |
force_detach = true / final_snapshot = true |
| Region | none (inherits provider) | n/a |
Other principles:
- One composite, one keystone.
aws_instance.thisowns only the satellites meaningless without it (extra EBS volumes + attachments, optional EIP). AMI, subnet, SGs, key pair, instance profile, CMK, placement group, and reservation are consumed by reference from sibling modules β blast radius stays at one host. - Autoscaling is out of scope. For elastic fleets use
tf-mod-aws-autoscaling-group+tf-mod-aws-launch-template; this module is for pets / fixed hosts. for_each, nevercount, for the data-volume collection and the guarded EIP, keyed by stable caller strings so reorders don't churn the plan.- Secrets never in user data. User data is readable from IMDS β keep credentials in Secrets Manager/SSM and fetch at boot via the instance profile.
- Primary outputs
id+arn, plus networking/DNS, storage, EIP, andtags_all.
# Validate without backend or credentials
terraform init -backend=false
terraform validate
terraform fmt -check
plan/applyrequire valid AWS credentials (profile / SSO / OIDC) resolved through the standard provider chain, a configured region, an existing AMI / subnet / security groups (and CMK / instance profile if used), plus the IAM actions listed above.
terraform init -backend=false && terraform validateβ schema + reference integrity.terraform fmt -checkβ canonical formatting.terraform planagainst a sandbox account to confirm the instance, IMDSv2 block, encrypted root volume, extra EBS volumes + attachments, and optional EIP materialize as expected.- Assert
module.<name>.arn,private_ip,ebs_volume_ids, andtags_allin your root-module test harness; confirmhttp_tokens = "required"on the rendered metadata options.
module.app_host.aws_instance.this: Creation complete after 22s [id=i-0abc123def4567890]
module.app_host.aws_ebs_volume.this["data"]: Creation complete [id=vol-0fed987...]
module.app_host.aws_volume_attachment.this["data"]: Creation complete [id=...]
Outputs:
arn = "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123def4567890"
id = "i-0abc123def4567890"
private_ip = "10.0.12.84"
availability_zone = "us-east-1a"
ebs_volume_ids = { "data" = "vol-0fed987..." }
| Symptom | Likely cause | Fix |
|---|---|---|
not authorized to perform: iam:PassRole |
TF identity can't pass the instance-profile role to EC2 | Grant iam:PassRole on the role ARN, condition iam:PassedToService = ec2.amazonaws.com |
Tags missing despite default_tags |
Expecting default_tags in var.tags |
They merge into tags_all automatically; resource tags win on conflict β check tags_all, not tags |
Client.InvalidKMSKey.InvalidState / encrypted-EBS launch failure |
CMK key policy doesn't grant EC2/EBS | Grant the service kms:CreateGrant + kms:GenerateDataKeyWithoutPlaintext on the CMK |
InvalidAMIID.NotFound |
AMI not in the target Region (AMIs are Region-scoped) | Use an AMI present in the region (tf-mod-aws-ami) or the provider region is wrong |
VolumeInUse / detach hangs on destroy |
Volume busy / instance running | Module stops the instance before detach by default; ensure it's not blocked, or set force_detach (risk of data loss) |
AddressLimitExceeded |
EIP quota (default 5/Region) reached | Request a quota increase, or release unused EIPs |
| Cannot reach instance metadata from a container | IMDSv2 hop limit is 1 (by default) |
Raise metadata_options.http_put_response_hop_limit (documented exception) |
OperationNotPermitted on destroy |
disable_api_termination = true |
Set it false and re-apply before destroy |
| Credential-chain failure on plan/apply | No resolvable AWS credentials/region | Configure AWS_PROFILE / SSO / OIDC and a region at the root/pipeline level |
Perpetual user_data diff / unexpected replace |
user_data_replace_on_change = true |
Set false for in-place stop/start, or accept replacement for immutable hosts |
- Amazon EC2 service quotas
- Use IMDSv2
- Amazon EBS encryption
- IAM roles for Amazon EC2 (instance profiles)
- Elastic IP addresses
- Terraform:
aws_instanceΒ·aws_ebs_volumeΒ·aws_volume_attachmentΒ·aws_eipΒ·aws_eip_association - Sibling modules:
tf-mod-aws-vpc,tf-mod-aws-security-group,tf-mod-aws-ami,tf-mod-aws-kms,tf-mod-aws-iam-role,tf-mod-aws-key-pair,tf-mod-aws-placement-group,tf-mod-aws-capacity-reservation,tf-mod-aws-autoscaling-group - Module internals:
SCOPE.md
π§‘ "Infrastructure as Code should be standardized, consistent, and secure."