Quick take — A production-ready Terraform module for the aws_instance resource on hashicorp/aws ~> 5.0 — IMDSv2-enforced, encrypted EBS, instance profiles, and gp3 root volumes baked in. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "aws" {
region = "us-east-1"
}
module "ec2_instance" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ec2-instance?ref=v1.0.0"
name = "..." # Logical name; used for the Name tag and child resource …
ami_id = "..." # AMI ID to launch (e.g. a hardened Amazon Linux 2023 ima…
subnet_id = "..." # Subnet to launch into. Determines the AZ and VPC.
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An EC2 instance is AWS’s virtual server: a unit of compute defined by an AMI, an instance type (vCPU/memory/network profile), one or more attached EBS volumes, and a placement inside a VPC subnet. The Terraform aws_instance resource exposes dozens of arguments and nested blocks — root_block_device, metadata_options, ebs_block_device, credit_specification, instance_market_options — and the defaults are not what most teams actually want. By default a raw aws_instance gives you IMDSv1 (the SSRF-credential-theft vector behind the 2019 Capital One breach), an unencrypted root volume, and a gp2 disk that is slower and pricier than gp3.
Wrapping aws_instance in a reusable module lets you encode the right defaults once and stamp them across every workload. This module enforces IMDSv2 (http_tokens = "required"), defaults the root volume to encrypted gp3, optionally attaches an IAM instance profile so the box can reach SSM/S3/Secrets Manager without static keys, and tags everything consistently. Callers supply intent (AMI, size, subnet, whether it needs a public IP) and the module supplies the hardening.
When to use it
- You are launching long-lived application servers — bastion/jump hosts, self-managed runners (GitHub Actions, GitLab, self-hosted Jenkins agents), licensed appliances, or legacy apps that cannot be containerized.
- You want a golden, hardened compute primitive every team reuses, instead of each squad re-discovering IMDSv2 and EBS encryption.
- You need a single named instance with predictable identity (Elastic IP, static private IP, SSM-managed) rather than a fleet behind a load balancer.
- Reach for something else when: you need horizontal autoscaling or rolling replacement — use a Launch Template + Auto Scaling Group; or the workload is stateless and short-lived — consider ECS/Fargate or Lambda. This module is for the cases where a specific, addressable VM is the right tool.
Module structure
terraform-module-aws-ec2-instance/
├── versions.tf # provider + Terraform version pins
├── main.tf # aws_instance + optional EIP, instance profile wiring
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id, arn, private/public IP, primary ENI, root volume id
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Merge a canonical Name tag with caller-supplied tags.
common_tags = merge(
var.tags,
{
Name = var.name
ManagedBy = "terraform"
Module = "terraform-module-aws-ec2-instance"
},
)
}
# Optional: wrap an existing IAM role in an instance profile so the
# instance can use SSM / S3 / Secrets Manager without long-lived keys.
resource "aws_iam_instance_profile" "this" {
count = var.create_instance_profile && var.iam_role_name != null ? 1 : 0
name = "${var.name}-profile"
role = var.iam_role_name
tags = local.common_tags
}
resource "aws_instance" "this" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = var.security_group_ids
key_name = var.key_name
associate_public_ip_address = var.associate_public_ip_address
private_ip = var.private_ip
# Attach the profile we created, or one the caller passes in by name.
iam_instance_profile = var.create_instance_profile && var.iam_role_name != null ? aws_iam_instance_profile.this[0].name : var.iam_instance_profile
# Render user_data on first boot; do NOT recreate the instance when it
# changes unless the caller explicitly opts in.
user_data = var.user_data
user_data_replace_on_change = var.user_data_replace_on_change
monitoring = var.detailed_monitoring
disable_api_termination = var.enable_termination_protection
instance_initiated_shutdown_behavior = var.shutdown_behavior
# Enforce IMDSv2 — defeats the SSRF -> instance-credential-theft path.
metadata_options {
http_endpoint = "enabled"
http_tokens = var.imdsv2_required ? "required" : "optional"
http_put_response_hop_limit = var.metadata_hop_limit
instance_metadata_tags = "enabled"
}
root_block_device {
volume_type = var.root_volume_type
volume_size = var.root_volume_size
iops = var.root_volume_type == "gp3" ? var.root_volume_iops : null
throughput = var.root_volume_type == "gp3" ? var.root_volume_throughput : null
encrypted = true
kms_key_id = var.root_volume_kms_key_id
delete_on_termination = var.root_volume_delete_on_termination
tags = merge(local.common_tags, { Name = "${var.name}-root" })
}
dynamic "credit_specification" {
# Only valid for burstable (T-family) instance types.
for_each = var.cpu_credits != null ? [1] : []
content {
cpu_credits = var.cpu_credits
}
}
tags = local.common_tags
volume_tags = local.common_tags
lifecycle {
# AMI changes should be a deliberate replacement, not silent drift.
ignore_changes = [ami]
}
}
# Optional static public address that survives stop/start.
resource "aws_eip" "this" {
count = var.allocate_eip ? 1 : 0
instance = aws_instance.this.id
domain = "vpc"
tags = merge(local.common_tags, { Name = "${var.name}-eip" })
}
variables.tf
variable "name" {
description = "Logical name; used for the Name tag and child resource names."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9._-]{1,255}$", var.name))
error_message = "name must be 1-255 chars: letters, digits, dot, underscore, hyphen."
}
}
variable "ami_id" {
description = "AMI ID to launch (e.g. a hardened Amazon Linux 2023 image)."
type = string
validation {
condition = can(regex("^ami-[0-9a-f]{8,17}$", var.ami_id))
error_message = "ami_id must look like 'ami-xxxxxxxx'."
}
}
variable "instance_type" {
description = "EC2 instance type, e.g. t3.micro, m6i.large."
type = string
default = "t3.micro"
}
variable "subnet_id" {
description = "Subnet to launch into. Determines the AZ and VPC."
type = string
}
variable "security_group_ids" {
description = "List of security group IDs to attach to the primary ENI."
type = list(string)
default = []
}
variable "key_name" {
description = "Name of an existing EC2 key pair for SSH. Null = no key (use SSM)."
type = string
default = null
}
variable "associate_public_ip_address" {
description = "Assign an auto-public IP. Leave false in private subnets."
type = bool
default = false
}
variable "private_ip" {
description = "Static private IPv4 within the subnet CIDR. Null = auto-assign."
type = string
default = null
}
variable "iam_instance_profile" {
description = "Name of an existing instance profile to attach (when not creating one)."
type = string
default = null
}
variable "create_instance_profile" {
description = "Wrap iam_role_name in a new instance profile owned by this module."
type = bool
default = false
}
variable "iam_role_name" {
description = "IAM role name to bind into the created instance profile."
type = string
default = null
}
variable "user_data" {
description = "Cloud-init / shell user data executed on first boot."
type = string
default = null
}
variable "user_data_replace_on_change" {
description = "Replace the instance when user_data changes (destructive)."
type = bool
default = false
}
variable "detailed_monitoring" {
description = "Enable 1-minute CloudWatch metrics (extra cost) vs default 5-minute."
type = bool
default = false
}
variable "enable_termination_protection" {
description = "Block accidental termination via the API/console."
type = bool
default = false
}
variable "shutdown_behavior" {
description = "Behavior on OS-initiated shutdown: 'stop' or 'terminate'."
type = string
default = "stop"
validation {
condition = contains(["stop", "terminate"], var.shutdown_behavior)
error_message = "shutdown_behavior must be 'stop' or 'terminate'."
}
}
variable "imdsv2_required" {
description = "Require IMDSv2 (http_tokens=required). Strongly recommended true."
type = bool
default = true
}
variable "metadata_hop_limit" {
description = "IMDS PUT response hop limit. Use 2 for containers needing IMDS."
type = number
default = 1
validation {
condition = var.metadata_hop_limit >= 1 && var.metadata_hop_limit <= 64
error_message = "metadata_hop_limit must be between 1 and 64."
}
}
variable "root_volume_type" {
description = "Root EBS volume type. gp3 is the cost/perf default."
type = string
default = "gp3"
validation {
condition = contains(["gp3", "gp2", "io1", "io2"], var.root_volume_type)
error_message = "root_volume_type must be one of gp3, gp2, io1, io2."
}
}
variable "root_volume_size" {
description = "Root EBS volume size in GiB."
type = number
default = 20
validation {
condition = var.root_volume_size >= 8 && var.root_volume_size <= 16384
error_message = "root_volume_size must be between 8 and 16384 GiB."
}
}
variable "root_volume_iops" {
description = "Provisioned IOPS for gp3 (3000-16000). Ignored for gp2."
type = number
default = 3000
}
variable "root_volume_throughput" {
description = "gp3 throughput in MiB/s (125-1000). Ignored for gp2."
type = number
default = 125
}
variable "root_volume_kms_key_id" {
description = "KMS key ARN for root volume encryption. Null = the aws/ebs key."
type = string
default = null
}
variable "root_volume_delete_on_termination" {
description = "Delete the root volume when the instance terminates."
type = bool
default = true
}
variable "cpu_credits" {
description = "Burstable credit mode for T-family: 'standard' or 'unlimited'. Null for non-T types."
type = string
default = null
validation {
condition = var.cpu_credits == null || contains(["standard", "unlimited"], coalesce(var.cpu_credits, "standard"))
error_message = "cpu_credits must be 'standard', 'unlimited', or null."
}
}
variable "allocate_eip" {
description = "Allocate and associate an Elastic IP (static, survives stop/start)."
type = bool
default = false
}
variable "tags" {
description = "Extra tags merged onto the instance and its volumes."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "The EC2 instance ID."
value = aws_instance.this.id
}
output "arn" {
description = "The ARN of the instance."
value = aws_instance.this.arn
}
output "name" {
description = "The logical Name tag of the instance."
value = var.name
}
output "private_ip" {
description = "Private IPv4 address assigned to the primary ENI."
value = aws_instance.this.private_ip
}
output "public_ip" {
description = "Public IPv4 address (EIP if allocated, else auto-assigned)."
value = var.allocate_eip ? aws_eip.this[0].public_ip : aws_instance.this.public_ip
}
output "primary_network_interface_id" {
description = "ID of the primary ENI — useful for attaching extra SGs or flow logs."
value = aws_instance.this.primary_network_interface_id
}
output "root_block_device_volume_id" {
description = "EBS volume ID of the root device — for snapshots/backup policies."
value = one(aws_instance.this.root_block_device[*].volume_id)
}
output "instance_profile_name" {
description = "Name of the instance profile in use, if any."
value = var.create_instance_profile && var.iam_role_name != null ? aws_iam_instance_profile.this[0].name : var.iam_instance_profile
}
How to use it
# Look up the latest hardened Amazon Linux 2023 AMI instead of hardcoding.
data "aws_ssm_parameter" "al2023" {
name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}
module "ec2_instance" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ec2-instance?ref=v1.0.0"
name = "kloudvin-runner-01"
ami_id = data.aws_ssm_parameter.al2023.value
instance_type = "t3.medium"
subnet_id = module.network.private_subnet_ids[0]
security_group_ids = [aws_security_group.runner.id]
# No SSH key — manage exclusively through SSM Session Manager.
create_instance_profile = true
iam_role_name = aws_iam_role.ssm_managed.name
# Burstable, encrypted, generous root volume for build caches.
cpu_credits = "unlimited"
root_volume_size = 50
root_volume_type = "gp3"
user_data = base64encode(<<-EOT
#!/bin/bash
dnf update -y
dnf install -y amazon-ssm-agent
systemctl enable --now amazon-ssm-agent
EOT
)
tags = {
Environment = "prod"
Team = "platform"
CostCenter = "1042"
}
}
# Downstream reference: open the build artifact bucket policy to this
# specific instance's private IP, and record the volume for backups.
resource "aws_dlm_lifecycle_policy" "runner_snapshots" {
description = "Daily snapshots of the runner root volume"
execution_role_arn = aws_iam_role.dlm.arn
state = "ENABLED"
policy_details {
resource_types = ["VOLUME"]
target_tags = {
Name = "${module.ec2_instance.name}-root"
}
schedule {
name = "daily"
create_rule { interval = 24, interval_unit = "HOURS", times = ["03:00"] }
retain_rule { count = 7 }
}
}
}
output "runner_private_ip" {
value = module.ec2_instance.private_ip
}
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...s3 state bucket/container + key per path...
}
}
2. Module config — live/prod/ec2_instance/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ec2-instance?ref=v1.0.0"
}
inputs = {
name = "..."
ami_id = "..."
subnet_id = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/ec2_instance && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Logical name; used for the Name tag and child resource names. |
ami_id |
string |
— | Yes | AMI ID to launch (e.g. a hardened Amazon Linux 2023 image). |
instance_type |
string |
"t3.micro" |
No | EC2 instance type, e.g. t3.micro, m6i.large. |
subnet_id |
string |
— | Yes | Subnet to launch into. Determines the AZ and VPC. |
security_group_ids |
list(string) |
[] |
No | Security group IDs attached to the primary ENI. |
key_name |
string |
null |
No | Existing EC2 key pair for SSH. Null = use SSM. |
associate_public_ip_address |
bool |
false |
No | Assign an auto-public IP. Keep false in private subnets. |
private_ip |
string |
null |
No | Static private IPv4 within the subnet CIDR. |
iam_instance_profile |
string |
null |
No | Existing instance profile name to attach. |
create_instance_profile |
bool |
false |
No | Wrap iam_role_name in a module-owned instance profile. |
iam_role_name |
string |
null |
No | IAM role name to bind into the created profile. |
user_data |
string |
null |
No | Cloud-init / shell user data run on first boot. |
user_data_replace_on_change |
bool |
false |
No | Replace the instance when user_data changes. |
detailed_monitoring |
bool |
false |
No | Enable 1-minute CloudWatch metrics (extra cost). |
enable_termination_protection |
bool |
false |
No | Block accidental API/console termination. |
shutdown_behavior |
string |
"stop" |
No | OS-initiated shutdown behavior: stop or terminate. |
imdsv2_required |
bool |
true |
No | Require IMDSv2 (http_tokens=required). |
metadata_hop_limit |
number |
1 |
No | IMDS PUT hop limit (1-64); use 2 for containers. |
root_volume_type |
string |
"gp3" |
No | Root EBS type: gp3, gp2, io1, io2. |
root_volume_size |
number |
20 |
No | Root EBS size in GiB (8-16384). |
root_volume_iops |
number |
3000 |
No | Provisioned IOPS for gp3 (ignored for gp2). |
root_volume_throughput |
number |
125 |
No | gp3 throughput MiB/s (ignored for gp2). |
root_volume_kms_key_id |
string |
null |
No | KMS key ARN for root encryption. Null = aws/ebs key. |
root_volume_delete_on_termination |
bool |
true |
No | Delete root volume on instance termination. |
cpu_credits |
string |
null |
No | T-family credit mode: standard or unlimited. |
allocate_eip |
bool |
false |
No | Allocate and associate an Elastic IP. |
tags |
map(string) |
{} |
No | Extra tags merged onto the instance and its volumes. |
Outputs
| Name | Description |
|---|---|
id |
The EC2 instance ID. |
arn |
The ARN of the instance. |
name |
The logical Name tag of the instance. |
private_ip |
Private IPv4 address on the primary ENI. |
public_ip |
Public IPv4 (EIP if allocated, else auto-assigned). |
primary_network_interface_id |
ID of the primary ENI (for extra SGs / flow logs). |
root_block_device_volume_id |
EBS volume ID of the root device (for snapshot policies). |
instance_profile_name |
Name of the instance profile in use, if any. |
Enterprise scenario
A financial-services platform team needs a fleet of self-hosted CI runners that can reach private artifact repositories but must never expose SSH or store long-lived AWS keys. They stamp out a dozen instances from this module across private subnets in two AZs, each with create_instance_profile = true bound to an SSM-managed role, imdsv2_required = true, and KMS-encrypted gp3 root volumes via root_volume_kms_key_id. The root_block_device_volume_id output feeds a Data Lifecycle Manager policy for daily snapshots, and because every box is reachable only through SSM Session Manager, the security team can retire the SSH bastion entirely — satisfying their CIS benchmark for “no inbound port 22.”
Best practices
- Enforce IMDSv2 everywhere. Keep
imdsv2_required = true(the default). IMDSv1 lets any SSRF bug exfiltrate the instance role’s temporary credentials; requiring session tokens closes that path. Usemetadata_hop_limit = 1for bare instances and2only when containers on the host genuinely need IMDS. - Use instance profiles, not access keys. Prefer
create_instance_profile+ an SSM-managed role over bakingaws_access_key_idinto user_data. Scope the role narrowly and rotate-free credentials are handled by AWS automatically. - Default to gp3 and right-size disks. gp3 is roughly 20% cheaper than gp2 at the same size and decouples IOPS/throughput from capacity. Set
root_volume_sizeto what you need plus headroom, not a copy-pasted 100 GiB, and always keepencrypted = true(this module hardcodes it). - Pin AMIs and make replacement deliberate. The module’s
lifecycle { ignore_changes = [ami] }stops Terraform from recreating the box every time adatasource resolves a newer image. Bump AMIs intentionally via ataint/-replaceso you control the maintenance window. - Protect stateful boxes, autoscale stateless ones. Set
enable_termination_protection = truefor instances holding state you cannot rebuild, andshutdown_behavior = "stop"so an in-guestshutdowndoesn’t destroy data. If you find yourself cloning this module N times for identical workers, switch to a Launch Template + ASG instead. - Tag for cost and ownership. Always pass
Environment,Team, andCostCenterviatags; the module propagates them to the instance and its EBS volumes throughvolume_tags, so Cost Explorer and backup policies can target them precisely.