IaC AWS

Terraform Module: AWS EC2 Instance — opinionated, secure-by-default compute

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

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 configlive/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 configlive/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

TerraformAWSEC2 InstanceModuleIaC
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

Keep Reading