Skip to content

microsoftexpert/tf-mod-aws-ec2-instance

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

2 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

🟧 AWS EC2 Instance Terraform Module

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.

Terraform aws module type resources


🧩 Overview

  • πŸ“ One host, fully wired. Creates aws_instance plus everything directly coupled to it: extra aws_ebs_volume data volumes, their aws_volume_attachments, and an optional aws_eip + aws_eip_association.
  • πŸ”’ IMDSv2 enforced by default. metadata_options defaults 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 via kms_key_arn for 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_each over map(object) keyed by a caller string (never count), so reorders don't churn the plan.
  • 🌐 Region-inherited. No region variable β€” 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.


❀️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

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!


πŸ—ΊοΈ Where this fits in the family

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
Loading

🧬 What this module builds

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
Loading
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

βœ… Provider / Versions

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).


πŸ”‘ Required IAM Permissions

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:PassRole is the one that bites. Attaching an instance profile fails with not authorized to perform: iam:PassRole unless the Terraform identity may pass that specific role to ec2.amazonaws.com. Scope it to the role ARN and add the iam:PassedToService condition.

πŸ”’ Scope ec2:* actions to a aws:ResourceTag/* condition (e.g. team/cost-center tag) so a pipeline can only touch its own hosts.


πŸ“‹ AWS Prerequisites

  • No service-linked role is required for a plain EC2 instance.
  • iam:PassRole (conditional). Attaching iam_instance_profile requires the Terraform identity to be permitted to pass that role to ec2.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 service kms:CreateGrant and kms:GenerateDataKeyWithoutPlaintext β€” otherwise the instance launch (or volume create) fails with a key-permission error. Wire kms_key_arn from tf-mod-aws-kms.
  • Networking. subnet_id and security_group_ids must 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 ami must be available in the target Region (AMIs are Region-scoped). Wire from tf-mod-aws-ami or 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.eip is set.
  • EBS volume storage and IOPS quotas per volume type per Region.
  • Region. No region variable β€” the resource inherits the caller's provider region. No us-east-1 global-service constraint applies to EC2.

πŸ“ Module Structure

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

βš™οΈ Quick Start

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"
  }
}

πŸ”Œ Cross-Module Contract

Consumes

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

Emits

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

πŸ“š Example Library

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" }
}

πŸ“₯ Inputs

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.


🧾 Outputs

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.

🧠 Architecture Notes

  • ARN format: arn:aws:ec2:<region>:<account-id>:instance/i-…. The id is the instance id (i-…). The ARN is the cross-resource reference type (IAM Resource, EventBridge rule patterns, AWS Config resource identity). EBS volume ARNs are arn:aws:ec2:<region>:<account-id>:volume/vol-…; EIPs are referenced by allocation id (eipalloc-…).
  • 🏷️ tags ↔ tags_all ↔ default_tags. var.tags (with a seeded Name from var.name) flows to the instance, the root volume, every extra aws_ebs_volume, and the optional aws_eip. tags_all is the computed merge of resource tags over provider default_tags β€” resource tags win on key conflict. default_tags is the caller's provider-block concern and is never set inside the module. Only the instance's tags_all is surfaced as an output.
  • Force-new fields. ami, subnet_id, availability_zone, placement_group, tenancy, key_name, associate_public_ip_address, private_ip, disabling enable_primary_ipv6, and user_data/user_data_base64 when user_data_replace_on_change = true all replace the instance. Most instance_type and other attribute changes apply in place via a stop/start. Replacing the instance generates a new id/arn and a fresh root volume.
  • Root vs extra volumes. The root volume lives in the root_block_device block of aws_instance and is removed with the instance (delete_on_termination = true by default). Extra data volumes are independent aws_ebs_volume + aws_volume_attachment resources 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_dns and public_ip populate once running. 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 = true blocks destroy until cleared.
  • us-east-1 globals: N/A β€” EC2 is a regional service with no global-endpoint constraint.

🧱 Design Principles

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.this owns 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, never count, 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, and tags_all.

πŸš€ Runbook

# Validate without backend or credentials
terraform init -backend=false
terraform validate
terraform fmt -check

plan / apply require 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.


πŸ§ͺ Testing

  • terraform init -backend=false && terraform validate β€” schema + reference integrity.
  • terraform fmt -check β€” canonical formatting.
  • terraform plan against 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, and tags_all in your root-module test harness; confirm http_tokens = "required" on the rendered metadata options.

πŸ’¬ Example Output

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..." }

πŸ” Troubleshooting

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

πŸ”— Related Docs


🧑 "Infrastructure as Code should be standardized, consistent, and secure."