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
- You need standalone data volumes (databases, Kafka logs, Elasticsearch indices, /var/lib/docker) that must outlive the EC2 instance lifecycle and be backed up independently of the root volume.
- You are migrating workloads from
gp2togp3to decouple IOPS/throughput from size and cut storage cost by roughly 20%, and you want the IOPS/throughput knobs exposed safely. - You require customer-managed KMS encryption on every volume for PCI-DSS, HIPAA, or internal compliance, and you cannot rely on the account default key.
- You want volumes tagged for Data Lifecycle Manager so snapshots are created and retained automatically without per-volume cron jobs.
- Use the EBS root/data volumes inside
aws_instanceor a launch template instead when the disk is ephemeral to the instance and never needs independent attachment or backup.
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 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/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
- Encrypt with a customer-managed key, always. Keep
encrypted = trueand setkms_key_idto a CMK with rotation enabled; the defaultaws/ebskey cannot be scoped per business unit or shared cross-account for snapshot copy. Enable EBS encryption-by-default at the account level as a backstop. - Prefer
gp3overgp2.gp3decouples IOPS/throughput from size — you pay for baseline 3000 IOPS / 125 MiB/s regardless of capacity and tune up only when needed, typically cutting cost ~20% versus equivalently sizedgp2. - Match the AZ to the instance. A volume can only attach to an instance in the same Availability Zone; surface
availability_zoneexplicitly and align it with the EC2 instance’s subnet to avoidInvalidVolume.ZoneMismatchat apply time. - Tag for lifecycle management, not ad-hoc scripts. Emit a consistent tag (e.g.
dlm-backup) and let Data Lifecycle Manager own snapshot creation, retention, and cross-region copy instead of bespoke Lambda or cron jobs. - Detach safely. For busy data volumes set
stop_instance_before_detaching = true, or unmount and flush filesystem buffers inside the OS first, so Terraform never force-detaches a live volume and corrupts data. - Right-size and review IOPS spend. Provisioned IOPS on
io2is billed per IOPS-month and can dwarf the capacity cost; monitorVolumeReadOps/VolumeWriteOpsin CloudWatch and drop over-provisioned volumes back togp3where burst performance suffices.