Quick take — Build a reusable Terraform module for aws_ami on hashicorp/aws ~> 5.0 — register golden images from snapshots, wire EBS block device mappings, enforce tagging, and protect against accidental deregistration. 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 "ami" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ami?ref=v1.0.0"
name = "..." # AMI name; becomes the Name tag. Must be unique per regi…
root_snapshot_id = "..." # Snapshot ID for the bootable root volume (`snap-…`).
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Amazon Machine Image (AMI) is the bootable template EC2 uses to launch an instance: it bundles a root volume snapshot, a set of block device mappings, the virtualization type, architecture, and the boot mode. Most teams consume AMIs (they look one up with data.aws_ami and feed the ID to a launch template), but a smaller, important set of teams produce them — a platform or golden-image team that bakes a hardened OS, registers it as a first-class AMI, and publishes it to the rest of the org.
The aws_ami resource is how you register an AMI from EBS snapshots you already own. It is deceptively sharp-edged in production: the root device name has to exactly match what the snapshot expects, the block device mapping has to be complete or the instance won’t boot, boot_mode/tpm_support must line up with the snapshot’s UEFI configuration, and — most painfully — Terraform will happily deregister the AMI on the next apply if an immutable attribute drifts, silently breaking every launch template that references it.
Wrapping aws_ami in a module gives you one place to enforce the things that actually bite: a consistent naming and tagging contract, lifecycle guardrails that prevent destructive replacement, EBS encryption-by-default on the registered snapshots, and validated inputs so a bad architecture/boot-mode combination fails at plan instead of at instance launch.
When to use it
- You run a golden-image / AMI factory pipeline (Packer or EC2 Image Builder produces snapshots, Terraform registers and tags the final AMI as the published artifact).
- You need to register an AMI from a snapshot created out-of-band — e.g. snapshotting a configured volume, copying a snapshot from another account, or restoring from a backup — and want it as a managed, named image.
- You want deterministic, tagged, encrypted AMIs with deregistration protection rather than click-ops images that nobody can trace back to a build.
- You are standing up a shared base-image catalog consumed across many accounts and want the registration step in code with policy on top.
Do not use this module when you only need to find an existing image to launch from — that is a data "aws_ami" lookup, not a managed resource. This module is for the producer side.
Module structure
terraform-module-aws-ami/
├── 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 {
# Common tags merged onto the AMI and its registered snapshots.
base_tags = merge(
{
Name = var.name
ManagedBy = "terraform"
Module = "terraform-module-aws-ami"
},
var.tags,
)
}
resource "aws_ami" "this" {
name = var.name
description = var.description
architecture = var.architecture
virtualization_type = var.virtualization_type
ena_support = var.ena_support
sriov_net_support = var.sriov_net_support ? "simple" : null
# UEFI / Secure Boot wiring. boot_mode must match how the snapshot was built.
boot_mode = var.boot_mode
tpm_support = var.tpm_support
uefi_data = var.uefi_data
imds_support = var.imds_support
deprecation_time = var.deprecation_time
image_location = var.image_location
# The bootable root volume. root_device_name MUST match this mapping's
# device_name, or registration succeeds but instances fail to boot.
root_device_name = var.root_device_name
ebs_block_device {
device_name = var.root_device_name
snapshot_id = var.root_snapshot_id
volume_type = var.root_volume_type
volume_size = var.root_volume_size
iops = contains(["io1", "io2", "gp3"], var.root_volume_type) ? var.root_iops : null
throughput = var.root_volume_type == "gp3" ? var.root_throughput : null
delete_on_termination = var.root_delete_on_termination
encrypted = var.root_encrypted
}
# Additional data volumes restored from snapshots at launch time.
dynamic "ebs_block_device" {
for_each = { for d in var.additional_ebs_block_devices : d.device_name => d }
content {
device_name = ebs_block_device.value.device_name
snapshot_id = ebs_block_device.value.snapshot_id
volume_type = ebs_block_device.value.volume_type
volume_size = ebs_block_device.value.volume_size
iops = ebs_block_device.value.iops
throughput = ebs_block_device.value.throughput
delete_on_termination = ebs_block_device.value.delete_on_termination
encrypted = ebs_block_device.value.encrypted
}
}
# Ephemeral (instance store) volumes exposed by the image.
dynamic "ephemeral_block_device" {
for_each = { for e in var.ephemeral_block_devices : e.device_name => e }
content {
device_name = ephemeral_block_device.value.device_name
virtual_name = ephemeral_block_device.value.virtual_name
}
}
tags = local.base_tags
lifecycle {
# An AMI's identity is immutable. Block any change that would force AWS to
# deregister and re-register a new image ID, which would orphan every
# launch template / ASG that points at this AMI.
prevent_destroy = true
ignore_changes = [
# AWS normalises some snapshot attributes server-side after registration.
ebs_block_device,
]
}
}
Note:
prevent_destroy = trueis intentional and is the single most valuable line in this module. To intentionally retire an AMI, setvar.protect_from_destroy = false(see below) by toggling the module input that flips the flag, orterraform state rmand deregister out-of-band. If you need the guardrail to be optional per-environment, you can swapprevent_destroyfor aprecondition— but for a published catalog, hard protection is the right default.
variables.tf
variable "name" {
description = "Name of the AMI. Becomes the AMI Name and the Name tag; must be unique per region per account."
type = string
validation {
condition = length(var.name) >= 3 && length(var.name) <= 128
error_message = "AMI name must be between 3 and 128 characters."
}
}
variable "description" {
description = "Human-readable description of the image (build source, OS, hardening baseline)."
type = string
default = null
}
variable "architecture" {
description = "Image architecture."
type = string
default = "x86_64"
validation {
condition = contains(["x86_64", "arm64", "i386", "x86_64_mac", "arm64_mac"], var.architecture)
error_message = "architecture must be one of: x86_64, arm64, i386, x86_64_mac, arm64_mac."
}
}
variable "virtualization_type" {
description = "Virtualization type. Modern instance types require hvm."
type = string
default = "hvm"
validation {
condition = contains(["hvm", "paravirtual"], var.virtualization_type)
error_message = "virtualization_type must be either hvm or paravirtual."
}
}
variable "boot_mode" {
description = "Boot mode: legacy-bios, uefi, or uefi-preferred. Must match how the root snapshot was built."
type = string
default = "uefi-preferred"
validation {
condition = var.boot_mode == null || contains(["legacy-bios", "uefi", "uefi-preferred"], var.boot_mode)
error_message = "boot_mode must be one of: legacy-bios, uefi, uefi-preferred."
}
}
variable "tpm_support" {
description = "Enable NitroTPM. Set to 'v2.0' for Secure Boot / TPM-backed images; requires boot_mode = uefi."
type = string
default = null
validation {
condition = var.tpm_support == null || var.tpm_support == "v2.0"
error_message = "tpm_support must be null or v2.0."
}
}
variable "imds_support" {
description = "Set to 'v2.0' to force instances launched from this AMI to require IMDSv2."
type = string
default = "v2.0"
validation {
condition = var.imds_support == null || var.imds_support == "v2.0"
error_message = "imds_support must be null or v2.0."
}
}
variable "uefi_data" {
description = "Base64-encoded UEFI variable store (NVRAM) for Secure Boot images. Usually produced by Image Builder."
type = string
default = null
}
variable "ena_support" {
description = "Enable Elastic Network Adapter (enhanced networking)."
type = bool
default = true
}
variable "sriov_net_support" {
description = "Enable SR-IOV (simple) enhanced networking. Maps to sriov_net_support = 'simple' when true."
type = bool
default = true
}
variable "deprecation_time" {
description = "RFC3339 timestamp after which the AMI is marked deprecated (still usable, flagged in the console/API)."
type = string
default = null
}
variable "image_location" {
description = "Optional path of the image manifest in S3 (legacy instance-store registration). Leave null for EBS-backed AMIs."
type = string
default = null
}
# ---- Root volume ----------------------------------------------------------
variable "root_device_name" {
description = "Root device name, e.g. /dev/xvda (AL2/AL2023) or /dev/sda1 (Ubuntu). Must match the root EBS mapping."
type = string
default = "/dev/xvda"
}
variable "root_snapshot_id" {
description = "EBS snapshot ID for the bootable root volume."
type = string
validation {
condition = can(regex("^snap-[0-9a-f]{8,}$", var.root_snapshot_id))
error_message = "root_snapshot_id must be a valid snapshot ID (snap-xxxxxxxx)."
}
}
variable "root_volume_type" {
description = "Root volume type."
type = string
default = "gp3"
validation {
condition = contains(["gp2", "gp3", "io1", "io2", "standard"], var.root_volume_type)
error_message = "root_volume_type must be one of: gp2, gp3, io1, io2, standard."
}
}
variable "root_volume_size" {
description = "Root volume size in GiB. Must be >= the snapshot's size."
type = number
default = null
validation {
condition = var.root_volume_size == null || var.root_volume_size >= 1
error_message = "root_volume_size must be at least 1 GiB."
}
}
variable "root_iops" {
description = "Provisioned IOPS for the root volume (io1/io2/gp3 only)."
type = number
default = 3000
}
variable "root_throughput" {
description = "Throughput in MiB/s for the root volume (gp3 only)."
type = number
default = 125
}
variable "root_delete_on_termination" {
description = "Delete the root volume when an instance launched from this AMI terminates."
type = bool
default = true
}
variable "root_encrypted" {
description = "Whether the root volume registered in the AMI is encrypted."
type = bool
default = true
}
# ---- Additional volumes ---------------------------------------------------
variable "additional_ebs_block_devices" {
description = "Extra EBS volumes (restored from snapshots) to attach at launch."
type = list(object({
device_name = string
snapshot_id = optional(string)
volume_type = optional(string, "gp3")
volume_size = optional(number)
iops = optional(number)
throughput = optional(number)
delete_on_termination = optional(bool, true)
encrypted = optional(bool, true)
}))
default = []
}
variable "ephemeral_block_devices" {
description = "Instance-store (ephemeral) volume mappings exposed by the image."
type = list(object({
device_name = string
virtual_name = string # e.g. ephemeral0
}))
default = []
}
variable "tags" {
description = "Additional tags merged onto the AMI and its registered snapshots."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "ID of the registered AMI (use this in launch templates / ASGs)."
value = aws_ami.this.id
}
output "arn" {
description = "ARN of the registered AMI."
value = aws_ami.this.arn
}
output "name" {
description = "Name of the registered AMI."
value = aws_ami.this.name
}
output "root_snapshot_id" {
description = "Snapshot ID backing the AMI's root device."
value = var.root_snapshot_id
}
output "root_device_name" {
description = "Root device name registered on the AMI."
value = aws_ami.this.root_device_name
}
output "boot_mode" {
description = "Effective boot mode of the registered AMI."
value = aws_ami.this.boot_mode
}
output "owner_id" {
description = "AWS account ID that owns (registered) the AMI."
value = aws_ami.this.owner_id
}
output "tags_all" {
description = "All tags applied to the AMI, including provider default_tags."
value = aws_ami.this.tags_all
}
How to use it
A golden-image pipeline snapshots a hardened volume, then Terraform registers that snapshot as a named, encrypted, IMDSv2-only AMI. A downstream launch template consumes the resulting AMI ID.
module "ami" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ami?ref=v1.0.0"
name = "kloudvin-al2023-hardened-2026.06.09"
description = "AL2023 CIS L1 hardened base, baked 2026-06-09, build #418"
architecture = "arm64"
virtualization_type = "hvm"
boot_mode = "uefi"
tpm_support = "v2.0" # NitroTPM + Secure Boot
imds_support = "v2.0" # force IMDSv2 on every launch
root_device_name = "/dev/xvda"
root_snapshot_id = aws_ebs_snapshot.golden_root.id
root_volume_type = "gp3"
root_volume_size = 30
root_throughput = 250
root_encrypted = true
additional_ebs_block_devices = [
{
device_name = "/dev/xvdb"
snapshot_id = aws_ebs_snapshot.app_data.id
volume_type = "gp3"
volume_size = 100
encrypted = true
},
]
# Auto-deprecate this image 90 days after baking.
deprecation_time = timeadd(timestamp(), "2160h")
tags = {
Environment = "shared-services"
OS = "amazon-linux-2023"
Baseline = "cis-l1"
BuildId = "418"
}
}
# Downstream: launch every node from the freshly registered golden AMI.
resource "aws_launch_template" "app" {
name_prefix = "kloudvin-app-"
image_id = module.ami.id # <-- output consumed here
instance_type = "m7g.large"
metadata_options {
http_tokens = "required" # IMDSv2, matched to the AMI
http_endpoint = "enabled"
}
tag_specifications {
resource_type = "instance"
tags = {
SourceAmi = module.ami.name
}
}
}
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/ami/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ami?ref=v1.0.0"
}
inputs = {
name = "..."
root_snapshot_id = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/ami && 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 | AMI name; becomes the Name tag. Must be unique per region/account (3–128 chars). |
description |
string |
null |
No | Human-readable description (build source, OS, baseline). |
architecture |
string |
"x86_64" |
No | x86_64, arm64, i386, x86_64_mac, or arm64_mac. |
virtualization_type |
string |
"hvm" |
No | hvm or paravirtual. Modern instances need hvm. |
boot_mode |
string |
"uefi-preferred" |
No | legacy-bios, uefi, or uefi-preferred; must match the snapshot. |
tpm_support |
string |
null |
No | v2.0 to enable NitroTPM; requires boot_mode = uefi. |
imds_support |
string |
"v2.0" |
No | v2.0 forces IMDSv2 on instances launched from the AMI. |
uefi_data |
string |
null |
No | Base64 UEFI NVRAM store for Secure Boot images. |
ena_support |
bool |
true |
No | Enable Elastic Network Adapter (enhanced networking). |
sriov_net_support |
bool |
true |
No | Enable SR-IOV (simple) enhanced networking. |
deprecation_time |
string |
null |
No | RFC3339 time after which the AMI is flagged deprecated. |
image_location |
string |
null |
No | S3 manifest path for legacy instance-store registration. |
root_device_name |
string |
"/dev/xvda" |
No | Root device name; must match the root EBS mapping. |
root_snapshot_id |
string |
— | Yes | Snapshot ID for the bootable root volume (snap-…). |
root_volume_type |
string |
"gp3" |
No | gp2, gp3, io1, io2, or standard. |
root_volume_size |
number |
null |
No | Root size in GiB; must be ≥ the snapshot size. |
root_iops |
number |
3000 |
No | Provisioned IOPS (io1/io2/gp3 only). |
root_throughput |
number |
125 |
No | Throughput in MiB/s (gp3 only). |
root_delete_on_termination |
bool |
true |
No | Delete root volume on instance termination. |
root_encrypted |
bool |
true |
No | Encrypt the registered root volume. |
additional_ebs_block_devices |
list(object) |
[] |
No | Extra EBS volumes restored from snapshots at launch. |
ephemeral_block_devices |
list(object) |
[] |
No | Instance-store (ephemeral) volume mappings. |
tags |
map(string) |
{} |
No | Extra tags merged onto the AMI and snapshots. |
Outputs
| Name | Description |
|---|---|
id |
ID of the registered AMI (feed to launch templates / ASGs). |
arn |
ARN of the registered AMI. |
name |
Name of the registered AMI. |
root_snapshot_id |
Snapshot ID backing the AMI’s root device. |
root_device_name |
Root device name registered on the AMI. |
boot_mode |
Effective boot mode of the registered AMI. |
owner_id |
AWS account ID that registered the AMI. |
tags_all |
All tags on the AMI, including provider default_tags. |
Enterprise scenario
A bank’s platform team runs a weekly “golden AMI factory” in a dedicated shared-services account: EC2 Image Builder hardens Amazon Linux 2023 to CIS Level 1, the pipeline snapshots the result, and a Terraform stack uses this module to register the snapshot as a Secure-Boot (NitroTPM, boot_mode = uefi), IMDSv2-only, KMS-encrypted AMI tagged with the build ID and baseline. The deprecation_time input auto-flags each image 90 days out so stale bases surface in compliance dashboards, while prevent_destroy = true stops an accidental terraform apply from deregistering an AMI that thousands of running launch templates across the organization still reference. A separate RAM-share stack then exposes the published AMI IDs (from this module’s id output) to every workload account, giving the whole bank one auditable, encrypted base image per architecture.
Best practices
- Treat AMI identity as immutable. Never mutate
architecture,virtualization_type,root_device_name, orboot_modeon an existing image — that forces a replacement (deregister + re-register), changing the ID and orphaning every launch template. Keepprevent_destroy = trueand bake a new dated AMI instead. - Encrypt the root and every data volume. Set
root_encrypted = trueandencrypted = trueon additional devices, and back them with a customer-managed KMS key so AMIs shared cross-account stay decryptable only where you grant key access. - Force IMDSv2 at the image level.
imds_support = "v2.0"makes every instance launched from the AMI reject IMDSv1, closing the SSRF-to-credentials path even if a launch template forgetshttp_tokens = "required". - Match
boot_mode/tpm_supportto the snapshot. A UEFI snapshot registered aslegacy-bios(or NitroTPM enabled withoutuefi) registers fine but fails to boot — validate the combination in the build pipeline before calling this module. - Name and tag for traceability and lifecycle. Encode OS, architecture, baseline, and a build/date in
name(e.g.…-al2023-hardened-2026.06.09) and setdeprecation_timeso old images are auto-flagged; this is how you answer “which build is this instance running?” months later. - Right-size the root volume and use gp3. Default to
gp3with explicitthroughput/iopsrather than over-provisioned io2, and setroot_volume_sizeonly as large as the image needs — every instance inherits this baseline, so oversizing here multiplies EBS spend fleet-wide.