Quick take — A production-ready Terraform module for aws_launch_template: version-driven instance config, IMDSv2 enforcement, encrypted EBS block mappings, and outputs wired for Auto Scaling Groups. 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 "launch_template" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-launch-template?ref=v1.0.0"
name = "..." # Name of the launch template; also the instance Name tag.
image_id = "..." # AMI ID to launch; resolve via SSM upstream to stay curr…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An AWS Launch Template is the canonical, versioned description of how an EC2 instance should be created: its AMI, instance type, key pair, security groups, IAM instance profile, block device mappings, user data, metadata options, and dozens of other knobs. Unlike the older Launch Configuration (now deprecated and immutable), a Launch Template supports versioning — every change creates a new numbered version, and consumers like Auto Scaling Groups (ASGs), EC2 Fleet, and Spot Fleet can pin to a specific version, $Latest, or $Default. This versioning is the whole reason it exists, and it is exactly what makes it awkward to manage by hand.
Wrapping aws_launch_template in a reusable module gives you a single, opinionated place to bake in the things every team forgets: IMDSv2 enforcement (so SSRF can’t steal instance credentials), encrypted EBS volumes by default, consistent tag propagation to instances and volumes, and a stable output contract (id + latest_version) that ASGs can reference. Teams stop copy-pasting 60-line aws_launch_template blocks and instead pass a handful of well-validated variables.
When to use it
- You run EC2 capacity behind an Auto Scaling Group, EC2 Fleet, or Spot Fleet and need a versioned template the ASG can roll through with instance refresh.
- You want to standardize instance hardening (IMDSv2 required, encrypted root volume, hop limit of 1) across many services without trusting each team to remember it.
- You need mixed instance policies or to flip an ASG between Spot and On-Demand without rewriting the instance definition — the template stays the same, the ASG decides the purchase option.
- You are migrating off deprecated
aws_launch_configurationand want versioned, immutable-history infrastructure. - You do not need this for a single, hand-managed pet instance — for that, a plain
aws_instanceis simpler. Launch Templates pay off when something else (an ASG/Fleet) launches instances from them repeatedly.
Module structure
terraform-module-aws-launch-template/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# main.tf
locals {
# Tags applied to the template object itself, merged into every tag spec.
base_tags = merge(
var.tags,
{
"ManagedBy" = "terraform"
"Module" = "terraform-module-aws-launch-template"
}
)
}
resource "aws_launch_template" "this" {
name = var.name
description = var.description
# Use $Latest as the default version so new ASG launches pick up changes.
update_default_version = var.update_default_version
image_id = var.image_id
instance_type = var.instance_type
key_name = var.key_name
# EBS-optimized is free on all current-gen instances; default it on.
ebs_optimized = var.ebs_optimized
# Security groups by ID (works in any VPC, unlike group names).
vpc_security_group_ids = var.vpc_security_group_ids
# user_data must be base64-encoded for launch templates.
user_data = var.user_data == null ? null : base64encode(var.user_data)
dynamic "iam_instance_profile" {
for_each = var.iam_instance_profile_arn == null ? [] : [1]
content {
arn = var.iam_instance_profile_arn
}
}
# Enforce IMDSv2: token required, low hop limit blocks container/SSRF abuse.
metadata_options {
http_endpoint = "enabled"
http_tokens = var.http_tokens
http_put_response_hop_limit = var.http_put_response_hop_limit
instance_metadata_tags = var.enable_instance_metadata_tags ? "enabled" : "disabled"
}
monitoring {
enabled = var.enable_detailed_monitoring
}
# Encrypted, sized root + extra data volumes.
dynamic "block_device_mappings" {
for_each = var.block_device_mappings
content {
device_name = block_device_mappings.value.device_name
ebs {
volume_size = block_device_mappings.value.volume_size
volume_type = block_device_mappings.value.volume_type
iops = lookup(block_device_mappings.value, "iops", null)
throughput = lookup(block_device_mappings.value, "throughput", null)
encrypted = lookup(block_device_mappings.value, "encrypted", true)
kms_key_id = lookup(block_device_mappings.value, "kms_key_id", null)
delete_on_termination = lookup(block_device_mappings.value, "delete_on_termination", true)
}
}
}
# Optional: place instances in a tenancy/affinity-controlled placement.
dynamic "placement" {
for_each = var.placement == null ? [] : [var.placement]
content {
availability_zone = lookup(placement.value, "availability_zone", null)
group_name = lookup(placement.value, "group_name", null)
tenancy = lookup(placement.value, "tenancy", null)
}
}
# Optional: attach Capacity Reservations or burstable credit spec.
dynamic "credit_specification" {
for_each = var.cpu_credits == null ? [] : [1]
content {
cpu_credits = var.cpu_credits
}
}
# Propagate tags to the instances and the volumes the template creates.
tag_specifications {
resource_type = "instance"
tags = merge(local.base_tags, { "Name" = var.name })
}
tag_specifications {
resource_type = "volume"
tags = merge(local.base_tags, { "Name" = "${var.name}-volume" })
}
tag_specifications {
resource_type = "network-interface"
tags = local.base_tags
}
tags = local.base_tags
# A new version is created on every change; let ASGs drain the old one.
lifecycle {
create_before_destroy = true
}
}
# variables.tf
variable "name" {
description = "Name of the launch template. Also used as the instance Name tag."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9.()/_-]{3,125}$", var.name))
error_message = "name must be 3-125 chars: letters, numbers, and . ( ) / _ - only."
}
}
variable "description" {
description = "Free-text description of the launch template."
type = string
default = "Managed by Terraform"
}
variable "image_id" {
description = "AMI ID to launch (e.g. ami-0abc...). Use an SSM parameter data source upstream to keep it current."
type = string
validation {
condition = can(regex("^ami-[0-9a-f]{8,17}$", var.image_id))
error_message = "image_id must be a valid AMI ID like ami-0123456789abcdef0."
}
}
variable "instance_type" {
description = "EC2 instance type (e.g. m6i.large). Can be overridden by an ASG mixed instances policy."
type = string
default = "t3.micro"
}
variable "key_name" {
description = "Name of an existing EC2 key pair. Leave null to rely on SSM Session Manager (recommended)."
type = string
default = null
}
variable "ebs_optimized" {
description = "Whether the instance is EBS-optimized. Free and recommended on current-gen types."
type = bool
default = true
}
variable "vpc_security_group_ids" {
description = "List of security group IDs to attach to the primary network interface."
type = list(string)
default = []
}
variable "iam_instance_profile_arn" {
description = "ARN of the IAM instance profile to attach. Null to launch with no instance role."
type = string
default = null
}
variable "user_data" {
description = "Plain-text user data. The module base64-encodes it for you. Null for none."
type = string
default = null
}
variable "update_default_version" {
description = "Set the newly created version as the template's default version on every apply."
type = bool
default = true
}
variable "http_tokens" {
description = "IMDS token requirement. 'required' enforces IMDSv2 and blocks IMDSv1."
type = string
default = "required"
validation {
condition = contains(["required", "optional"], var.http_tokens)
error_message = "http_tokens must be 'required' (IMDSv2 only) or 'optional'."
}
}
variable "http_put_response_hop_limit" {
description = "IMDS hop limit. Keep at 1 for bare EC2; raise to 2 only for containerized workloads that proxy IMDS."
type = number
default = 1
validation {
condition = var.http_put_response_hop_limit >= 1 && var.http_put_response_hop_limit <= 64
error_message = "http_put_response_hop_limit must be between 1 and 64."
}
}
variable "enable_instance_metadata_tags" {
description = "Expose instance tags through the metadata endpoint (tags as a data source on the box)."
type = bool
default = false
}
variable "enable_detailed_monitoring" {
description = "Enable 1-minute CloudWatch detailed monitoring (incurs cost) instead of 5-minute basic."
type = bool
default = false
}
variable "block_device_mappings" {
description = <<-EOT
List of EBS block device mappings. Each object supports:
device_name (e.g. /dev/xvda), volume_size (GiB), volume_type (gp3/io2/...),
and optionally iops, throughput, encrypted, kms_key_id, delete_on_termination.
EOT
type = list(object({
device_name = string
volume_size = number
volume_type = optional(string, "gp3")
iops = optional(number)
throughput = optional(number)
encrypted = optional(bool, true)
kms_key_id = optional(string)
delete_on_termination = optional(bool, true)
}))
default = [
{
device_name = "/dev/xvda"
volume_size = 30
volume_type = "gp3"
}
]
validation {
condition = alltrue([for m in var.block_device_mappings : m.volume_size >= 1 && m.volume_size <= 16384])
error_message = "Each volume_size must be between 1 and 16384 GiB."
}
}
variable "placement" {
description = "Optional placement block: object with availability_zone, group_name, and/or tenancy."
type = object({
availability_zone = optional(string)
group_name = optional(string)
tenancy = optional(string)
})
default = null
}
variable "cpu_credits" {
description = "Credit spec for burstable (T-family) instances: 'standard' or 'unlimited'. Null to inherit type default."
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 "tags" {
description = "Tags applied to the template and propagated to instances, volumes, and ENIs."
type = map(string)
default = {}
}
# outputs.tf
output "id" {
description = "The ID of the launch template."
value = aws_launch_template.this.id
}
output "arn" {
description = "The ARN of the launch template."
value = aws_launch_template.this.arn
}
output "name" {
description = "The name of the launch template."
value = aws_launch_template.this.name
}
output "latest_version" {
description = "The latest version number of the launch template."
value = aws_launch_template.this.latest_version
}
output "default_version" {
description = "The default version number of the launch template."
value = aws_launch_template.this.default_version
}
How to use it
The most common consumer is an Auto Scaling Group that references the template and rides $Latest (so an instance refresh picks up new versions). Resolve the AMI from SSM Parameter Store so it tracks the latest patched image instead of pinning a stale ID.
data "aws_ssm_parameter" "al2023" {
name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}
module "launch_template" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-launch-template?ref=v1.0.0"
name = "payments-api"
description = "Payments API worker nodes"
image_id = data.aws_ssm_parameter.al2023.value
instance_type = "m6i.large"
vpc_security_group_ids = [aws_security_group.payments.id]
iam_instance_profile_arn = aws_iam_instance_profile.payments.arn
# IMDSv2 enforced, hop limit 1 by default — no extra config needed.
enable_detailed_monitoring = true
block_device_mappings = [
{
device_name = "/dev/xvda"
volume_size = 50
volume_type = "gp3"
throughput = 250
iops = 3000
kms_key_id = aws_kms_key.ebs.arn
}
]
user_data = <<-EOT
#!/bin/bash
dnf install -y amazon-cloudwatch-agent
systemctl enable --now amazon-cloudwatch-agent
EOT
tags = {
Environment = "prod"
Team = "payments"
}
}
# Downstream: an ASG consumes the module's id + latest_version output.
resource "aws_autoscaling_group" "payments" {
name = "payments-api"
min_size = 3
max_size = 12
desired_capacity = 3
vpc_zone_identifier = var.private_subnet_ids
launch_template {
id = module.launch_template.id
version = module.launch_template.latest_version
}
instance_refresh {
strategy = "Rolling"
preferences {
min_healthy_percentage = 75
}
}
tag {
key = "Name"
value = "payments-api"
propagate_at_launch = true
}
}
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/launch_template/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-launch-template?ref=v1.0.0"
}
inputs = {
name = "..."
image_id = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/launch_template && 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 |
n/a | Yes | Name of the launch template; also the instance Name tag. |
description |
string |
"Managed by Terraform" |
No | Free-text description of the template. |
image_id |
string |
n/a | Yes | AMI ID to launch; resolve via SSM upstream to stay current. |
instance_type |
string |
"t3.micro" |
No | EC2 instance type; overridable by an ASG mixed instances policy. |
key_name |
string |
null |
No | Existing EC2 key pair name; null to use SSM Session Manager. |
ebs_optimized |
bool |
true |
No | Whether the instance is EBS-optimized. |
vpc_security_group_ids |
list(string) |
[] |
No | Security group IDs for the primary network interface. |
iam_instance_profile_arn |
string |
null |
No | ARN of the IAM instance profile to attach. |
user_data |
string |
null |
No | Plain-text user data; module base64-encodes it. |
update_default_version |
bool |
true |
No | Make each new version the template default on apply. |
http_tokens |
string |
"required" |
No | IMDS token mode; required enforces IMDSv2. |
http_put_response_hop_limit |
number |
1 |
No | IMDS hop limit (1 for bare EC2, 2 for containers). |
enable_instance_metadata_tags |
bool |
false |
No | Expose instance tags via the metadata endpoint. |
enable_detailed_monitoring |
bool |
false |
No | Enable 1-minute CloudWatch detailed monitoring. |
block_device_mappings |
list(object) |
gp3 30 GiB root | No | EBS block device mappings (size, type, iops, throughput, encryption). |
placement |
object |
null |
No | Placement block: availability_zone, group_name, tenancy. |
cpu_credits |
string |
null |
No | Burstable credit spec: standard or unlimited. |
tags |
map(string) |
{} |
No | Tags for the template, instances, volumes, and ENIs. |
Outputs
| Name | Description |
|---|---|
id |
The ID of the launch template. |
arn |
The ARN of the launch template. |
name |
The name of the launch template. |
latest_version |
The latest version number of the launch template. |
default_version |
The default version number of the launch template. |
Enterprise scenario
A retail platform team standardizes every stateless service fleet on this module so that security can mandate one control set globally: http_tokens = "required" and a hop limit of 1 are non-negotiable defaults, which closed an SSRF-to-credential-theft path that an old IMDSv1 fleet had left open. Each service’s Auto Scaling Group pins to module.launch_template.latest_version, so a quarterly AMI refresh from the golden-image pipeline becomes a one-line image_id change plus a rolling instance refresh — no per-team template surgery, and a full version history in EC2 for audit and rollback.
Best practices
- Enforce IMDSv2 everywhere. Keep
http_tokens = "required"andhttp_put_response_hop_limit = 1. Only raise the hop limit to 2 for containerized workloads that legitimately proxy metadata; a higher limit lets a compromised container reach instance role credentials. - Encrypt every EBS volume. The module defaults
encrypted = true; supply a customer-managedkms_key_idfor sensitive fleets so you control key rotation and can revoke access independently of the instance. - Resolve AMIs from SSM, never hardcode. Feed
image_idfromaws_ssm_parameter(or your golden-image pipeline) so each new template version tracks patched images, and let ASG instance refresh roll them out. - Pin ASGs to
latest_version, not$Default, for predictable rollouts. Combine withupdate_default_version = trueso the audit trail and the live fleet agree, and so a bad version is a one-step rollback to the prior number. - Right-size with gp3 over gp2. gp3 decouples IOPS/throughput from size and is ~20% cheaper per GiB; set explicit
iops/throughputonly when a workload needs more than the 3000 IOPS / 125 MB/s baseline. - Name and tag consistently. Use a
service-environmentnaming convention and rely ontag_specifications(instance, volume, network-interface) so cost allocation and ownership tags reach every resource the template spawns, not just the template object.