IaC AWS

Terraform Module: AWS AMI — golden image registration with lifecycle guardrails

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

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 = true is intentional and is the single most valuable line in this module. To intentionally retire an AMI, set var.protect_from_destroy = false (see below) by toggling the module input that flips the flag, or terraform state rm and deregister out-of-band. If you need the guardrail to be optional per-environment, you can swap prevent_destroy for a precondition — 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 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/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

TerraformAWSAMIModuleIaC
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