IaC AWS

Terraform Module: AWS EBS Volume — Encrypted, Tagged, Snapshot-Ready Block Storage

Quick take — A reusable Terraform module for provisioning AWS EBS volumes with KMS encryption, gp3/io2 throughput tuning, instance attachment, and DLM-friendly tagging on hashicorp/aws ~> 5.0. 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 "ebs_volume" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ebs-volume?ref=v1.0.0"

  name              = "..."  # Name tag for the volume; also used for DLM targeting.
  availability_zone = "..."  # AZ for the volume; must match the target instance. Immu…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon EBS (Elastic Block Store) provides persistent, network-attached block storage for EC2 instances. Unlike instance store, an EBS volume survives instance stop/start and termination, can be detached and re-attached to another instance in the same Availability Zone, and can be snapshotted to S3 for backup or cross-region copy. A single aws_ebs_volume carries a surprising amount of nuance: the volume type (gp3, gp2, io2, io2 Block Express, st1, sc1), provisioned IOPS and throughput (which only apply to specific types), encryption with a customer-managed KMS key, and the Availability Zone — which is immutable and must match the EC2 instance you intend to attach it to.

Wrapping this in a module matters because raw EBS usage leaks two classes of mistakes into production. First, encryption drift: it is trivially easy to forget encrypted = true or to let AWS fall back to the default aws/ebs KMS key when compliance demands a customer-managed key. Second, invalid type/IOPS/throughput combinations: setting iops on a gp2 volume, or throughput on io2, produces confusing apply errors or silently ignored arguments. This module centralises encryption-by-default, validates the type matrix, optionally attaches the volume to an instance via aws_volume_attachment, and emits consistent tags so a Data Lifecycle Manager (DLM) policy can find and snapshot the volume automatically.

When to use it

Module structure

terraform-module-aws-ebs-volume/
├── 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 {
  # IOPS only applies to gp3, io1, io2. Throughput only applies to gp3.
  iops_supported       = contains(["gp3", "io1", "io2"], var.volume_type)
  throughput_supported = var.volume_type == "gp3"

  base_tags = {
    Name       = var.name
    managed-by = "terraform"
    component  = "ebs-volume"
  }

  tags = merge(local.base_tags, var.tags)
}

resource "aws_ebs_volume" "this" {
  availability_zone = var.availability_zone
  size              = var.snapshot_id == null ? var.size : null
  type              = var.volume_type
  snapshot_id       = var.snapshot_id

  # Conditionally set IOPS/throughput only for types that support them,
  # otherwise leave null so AWS applies the type default.
  iops       = local.iops_supported ? var.iops : null
  throughput = local.throughput_supported ? var.throughput : null

  encrypted  = var.encrypted
  kms_key_id = var.encrypted ? var.kms_key_id : null

  multi_attach_enabled = var.multi_attach_enabled
  final_snapshot       = var.final_snapshot

  tags = local.tags

  lifecycle {
    precondition {
      condition     = !var.multi_attach_enabled || contains(["io1", "io2"], var.volume_type)
      error_message = "multi_attach_enabled is only supported on io1 and io2 volumes."
    }
  }
}

resource "aws_volume_attachment" "this" {
  count = var.instance_id != null ? 1 : 0

  device_name                    = var.device_name
  volume_id                      = aws_ebs_volume.this.id
  instance_id                    = var.instance_id
  stop_instance_before_detaching = var.stop_instance_before_detaching
}
# variables.tf
variable "name" {
  description = "Name tag for the EBS volume (used as the Name tag and for DLM targeting)."
  type        = string

  validation {
    condition     = length(var.name) > 0 && length(var.name) <= 255
    error_message = "name must be between 1 and 255 characters."
  }
}

variable "availability_zone" {
  description = "AZ in which to create the volume. Must match the instance it will attach to (e.g. ap-south-1a). Immutable."
  type        = string
}

variable "size" {
  description = "Size of the volume in GiB. Ignored when snapshot_id is set (size is inherited from the snapshot unless grown)."
  type        = number
  default     = 20

  validation {
    condition     = var.size >= 1 && var.size <= 65536
    error_message = "size must be between 1 and 65536 GiB."
  }
}

variable "volume_type" {
  description = "EBS volume type."
  type        = string
  default     = "gp3"

  validation {
    condition     = contains(["gp2", "gp3", "io1", "io2", "st1", "sc1", "standard"], var.volume_type)
    error_message = "volume_type must be one of gp2, gp3, io1, io2, st1, sc1, standard."
  }
}

variable "iops" {
  description = "Provisioned IOPS. Applies only to gp3 (3000-16000), io1, io2. Ignored for other types."
  type        = number
  default     = null

  validation {
    condition     = var.iops == null || (var.iops >= 100 && var.iops <= 256000)
    error_message = "iops must be between 100 and 256000 when set."
  }
}

variable "throughput" {
  description = "Throughput in MiB/s. Applies only to gp3 (125-1000). Ignored for other types."
  type        = number
  default     = null

  validation {
    condition     = var.throughput == null || (var.throughput >= 125 && var.throughput <= 1000)
    error_message = "throughput must be between 125 and 1000 MiB/s when set."
  }
}

variable "encrypted" {
  description = "Whether the volume is encrypted. Strongly recommended to keep true."
  type        = bool
  default     = true
}

variable "kms_key_id" {
  description = "ARN of the customer-managed KMS key. If null while encrypted=true, AWS uses the default aws/ebs key."
  type        = string
  default     = null
}

variable "snapshot_id" {
  description = "Snapshot ID to restore the volume from. When set, size is inherited from the snapshot."
  type        = string
  default     = null
}

variable "multi_attach_enabled" {
  description = "Enable Multi-Attach (io1/io2 only) so the volume can attach to multiple instances in the same AZ."
  type        = bool
  default     = false
}

variable "final_snapshot" {
  description = "Take a final snapshot before the volume is deleted by Terraform."
  type        = bool
  default     = false
}

variable "instance_id" {
  description = "EC2 instance ID to attach the volume to. If null, no attachment is created (standalone volume)."
  type        = string
  default     = null
}

variable "device_name" {
  description = "Device name exposed to the instance (e.g. /dev/sdf). Required when instance_id is set."
  type        = string
  default     = "/dev/sdf"
}

variable "stop_instance_before_detaching" {
  description = "Stop the instance before detaching the volume to avoid data corruption on busy volumes."
  type        = bool
  default     = false
}

variable "tags" {
  description = "Additional tags merged onto the volume (e.g. dlm-backup, env, owner)."
  type        = map(string)
  default     = {}
}
# outputs.tf
output "id" {
  description = "The ID of the EBS volume."
  value       = aws_ebs_volume.this.id
}

output "arn" {
  description = "The ARN of the EBS volume."
  value       = aws_ebs_volume.this.arn
}

output "name" {
  description = "The Name tag of the EBS volume."
  value       = var.name
}

output "availability_zone" {
  description = "The AZ in which the volume resides."
  value       = aws_ebs_volume.this.availability_zone
}

output "size" {
  description = "The size of the volume in GiB."
  value       = aws_ebs_volume.this.size
}

output "encrypted" {
  description = "Whether the volume is encrypted."
  value       = aws_ebs_volume.this.encrypted
}

output "attachment_id" {
  description = "The ID of the volume attachment, or null if the volume is standalone."
  value       = try(aws_volume_attachment.this[0].id, null)
}

output "device_name" {
  description = "The device name the volume is attached as, or null if standalone."
  value       = try(aws_volume_attachment.this[0].device_name, null)
}

How to use it

# Customer-managed key for EBS encryption
resource "aws_kms_key" "ebs" {
  description             = "CMK for production EBS data volumes"
  deletion_window_in_days = 30
  enable_key_rotation     = true
}

# A gp3 data volume attached to an existing database instance
module "ebs_volume" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ebs-volume?ref=v1.0.0"

  name              = "prod-postgres-data"
  availability_zone = "ap-south-1a"
  size              = 500
  volume_type       = "gp3"
  iops              = 6000
  throughput        = 250

  encrypted  = true
  kms_key_id = aws_kms_key.ebs.arn

  instance_id = aws_instance.postgres.id
  device_name = "/dev/sdf"

  tags = {
    env        = "prod"
    owner      = "data-platform"
    dlm-backup = "daily" # picked up by a Data Lifecycle Manager policy
  }
}

# Downstream reference: target this volume from a DLM lifecycle policy
resource "aws_dlm_lifecycle_policy" "ebs_daily" {
  description        = "Daily snapshots of tagged production volumes"
  execution_role_arn = aws_iam_role.dlm.arn
  state              = "ENABLED"

  policy_details {
    resource_types = ["VOLUME"]

    # Match the dlm-backup tag emitted by the module
    target_tags = {
      dlm-backup = "daily"
    }

    schedule {
      name = "daily-2am"

      create_rule {
        interval      = 24
        interval_unit = "HOURS"
        times         = ["02:00"]
      }

      retain_rule {
        count = 14
      }

      copy_tags = true
    }
  }
}

output "data_volume_arn" {
  value = module.ebs_volume.arn
}

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/ebs_volume/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ebs-volume?ref=v1.0.0"
}

inputs = {
  name = "..."
  availability_zone = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/ebs_volume && 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 tag for the volume; also used for DLM targeting.
availability_zone string n/a yes AZ for the volume; must match the target instance. Immutable.
size number 20 no Volume size in GiB (1–65536). Ignored when snapshot_id is set.
volume_type string "gp3" no One of gp2, gp3, io1, io2, st1, sc1, standard.
iops number null no Provisioned IOPS; applies to gp3/io1/io2 only.
throughput number null no Throughput in MiB/s (125–1000); applies to gp3 only.
encrypted bool true no Whether the volume is encrypted.
kms_key_id string null no Customer-managed KMS key ARN; falls back to aws/ebs if null.
snapshot_id string null no Snapshot to restore the volume from.
multi_attach_enabled bool false no Multi-Attach for io1/io2 volumes only.
final_snapshot bool false no Take a final snapshot before deletion.
instance_id string null no EC2 instance to attach to; null means standalone.
device_name string "/dev/sdf" no Device name exposed to the instance.
stop_instance_before_detaching bool false no Stop the instance before detaching.
tags map(string) {} no Additional tags merged onto the volume.

Outputs

Name Description
id The ID of the EBS volume.
arn The ARN of the EBS volume.
name The Name tag of the EBS volume.
availability_zone The AZ in which the volume resides.
size The size of the volume in GiB.
encrypted Whether the volume is encrypted.
attachment_id The ID of the volume attachment, or null if standalone.
device_name The device name the volume is attached as, or null if standalone.

Enterprise scenario

A fintech platform running self-managed PostgreSQL on EC2 across three Availability Zones uses this module to provision the 500 GiB gp3 data volumes that hold each replica’s /var/lib/pgsql. Every volume is encrypted with a per-business-unit customer-managed KMS key (satisfying the PCI-DSS scoping boundary) and tagged dlm-backup = "daily", so a single account-wide Data Lifecycle Manager policy snapshots all of them at 02:00 IST and retains 14 days. When a node needs replacement, the team restores from the latest snapshot by passing snapshot_id into the same module, bringing a warm replica online in minutes rather than rebuilding from a base backup.

Best practices

TerraformAWSEBS VolumeModuleIaC
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