Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for aws_dynamodb_table covering GSIs/LSIs, on-demand vs provisioned with target-tracking autoscaling, point-in-time recovery, TTL, streams, and KMS encryption. 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 "dynamodb" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-dynamodb?ref=v1.0.0"
name = "..." # Name of the DynamoDB table (3-255 chars).
hash_key = "..." # Partition (hash) key attribute name.
attributes = ["...", "..."] # Key attribute definitions for table + indexes; `type` i…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Amazon DynamoDB is a fully managed, serverless key-value and document database that delivers single-digit-millisecond performance at any scale. A table is the top-level resource: you define a partition key (and optionally a sort key), choose a billing mode, and DynamoDB handles partitioning, replication across three Availability Zones, and storage growth transparently.
The trouble is that a production-grade DynamoDB table is rarely just a partition key. In practice you almost always need Global Secondary Indexes (GSIs) for alternate access patterns, point-in-time recovery (PITR) so you can restore to any second in the last 35 days, server-side encryption with a customer-managed KMS key, a TTL attribute to auto-expire records, and — if you run in PROVISIONED mode — Application Auto Scaling target-tracking policies so you don’t pay for peak capacity 24/7 or get throttled during spikes. Wiring all of that by hand for every table is repetitive and easy to get subtly wrong (forgetting prevent_destroy, mismatching a GSI key schema against its declared attribute, leaving deletion protection off in prod).
This module wraps aws_dynamodb_table plus its autoscaling siblings (aws_appautoscaling_target / aws_appautoscaling_policy) behind a clean, var-driven interface. You declare the keys, attributes, and indexes once; the module enforces sane production defaults (PITR on, server-side encryption on, deletion protection on) and exposes the table ARN, name, and stream ARN as outputs for downstream IAM policies, Lambda event source mappings, and CloudWatch alarms.
When to use it
- You need an OLTP data store with predictable low latency and want to standardize table provisioning across many services or microservices.
- You are implementing single-table design and need several GSIs (e.g.
GSI1PK/GSI1SK) plus a sort key, defined declaratively. - You want DynamoDB Streams feeding a Lambda (CDC, outbox pattern, materialized views) and need the stream ARN wired into an event source mapping.
- You run spiky but predictable workloads in
PROVISIONEDmode and want target-tracking autoscaling instead of over-provisioning — or you want purePAY_PER_REQUESTand toggle it per environment with one variable. - You require compliance controls: encryption with a customer-managed CMK, PITR for restore SLAs, and deletion protection on production tables.
Reach for a different approach if you need a global multi-Region table with active-active replication — this module is single-Region. (You can extend it with replica {} blocks, but cross-Region replicas change the billing and PITR model and deserve their own module.)
Module structure
terraform-module-aws-dynamodb/
├── versions.tf # provider + Terraform version constraints
├── main.tf # aws_dynamodb_table + autoscaling resources
├── variables.tf # input variables with validation
└── outputs.tf # table id/arn/name, stream arn, GSI info
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# Autoscaling only applies in PROVISIONED mode.
provisioned = var.billing_mode == "PROVISIONED"
# Build a flat map of table + GSI capacity targets to autoscale.
# Key "table" handles the base table; each GSI keyed by its name.
autoscale_read = local.provisioned && var.autoscaling_enabled ? merge(
{ "table" = { resource_id = "table/${var.name}" } },
{
for idx in var.global_secondary_indexes :
idx.name => { resource_id = "table/${var.name}/index/${idx.name}" }
}
) : {}
autoscale_write = local.autoscale_read
}
resource "aws_dynamodb_table" "this" {
name = var.name
billing_mode = var.billing_mode
hash_key = var.hash_key
range_key = var.range_key
# Provisioned base-table capacity (ignored by AWS in PAY_PER_REQUEST mode).
read_capacity = local.provisioned ? var.read_capacity : null
write_capacity = local.provisioned ? var.write_capacity : null
table_class = var.table_class
deletion_protection_enabled = var.deletion_protection_enabled
# Every key used by the table or any index must be declared here.
dynamic "attribute" {
for_each = var.attributes
content {
name = attribute.value.name
type = attribute.value.type
}
}
dynamic "global_secondary_index" {
for_each = var.global_secondary_indexes
content {
name = global_secondary_index.value.name
hash_key = global_secondary_index.value.hash_key
range_key = lookup(global_secondary_index.value, "range_key", null)
projection_type = global_secondary_index.value.projection_type
non_key_attributes = lookup(global_secondary_index.value, "non_key_attributes", null)
# Only meaningful in PROVISIONED mode; null in on-demand.
read_capacity = local.provisioned ? lookup(global_secondary_index.value, "read_capacity", var.read_capacity) : null
write_capacity = local.provisioned ? lookup(global_secondary_index.value, "write_capacity", var.write_capacity) : null
}
}
dynamic "local_secondary_index" {
for_each = var.local_secondary_indexes
content {
name = local_secondary_index.value.name
range_key = local_secondary_index.value.range_key
projection_type = local_secondary_index.value.projection_type
non_key_attributes = lookup(local_secondary_index.value, "non_key_attributes", null)
}
}
dynamic "ttl" {
for_each = var.ttl_attribute_name != null ? [1] : []
content {
enabled = true
attribute_name = var.ttl_attribute_name
}
}
stream_enabled = var.stream_enabled
stream_view_type = var.stream_enabled ? var.stream_view_type : null
point_in_time_recovery {
enabled = var.point_in_time_recovery_enabled
}
server_side_encryption {
enabled = var.server_side_encryption_enabled
kms_key_arn = var.kms_key_arn
}
lifecycle {
# Capacity is managed by Application Auto Scaling once enabled,
# so Terraform must not fight it on subsequent plans.
ignore_changes = [read_capacity, write_capacity]
}
tags = merge(var.tags, { Name = var.name })
}
# ---------------------------------------------------------------------------
# Application Auto Scaling (PROVISIONED mode only)
# ---------------------------------------------------------------------------
resource "aws_appautoscaling_target" "read" {
for_each = local.autoscale_read
max_capacity = var.autoscaling_read_max_capacity
min_capacity = var.read_capacity
resource_id = each.value.resource_id
scalable_dimension = each.key == "table" ? "dynamodb:table:ReadCapacityUnits" : "dynamodb:index:ReadCapacityUnits"
service_namespace = "dynamodb"
}
resource "aws_appautoscaling_target" "write" {
for_each = local.autoscale_write
max_capacity = var.autoscaling_write_max_capacity
min_capacity = var.write_capacity
resource_id = each.value.resource_id
scalable_dimension = each.key == "table" ? "dynamodb:table:WriteCapacityUnits" : "dynamodb:index:WriteCapacityUnits"
service_namespace = "dynamodb"
}
resource "aws_appautoscaling_policy" "read" {
for_each = aws_appautoscaling_target.read
name = "${each.value.resource_id}-read-tracking"
policy_type = "TargetTrackingScaling"
resource_id = each.value.resource_id
scalable_dimension = each.value.scalable_dimension
service_namespace = each.value.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "DynamoDBReadCapacityUtilization"
}
target_value = var.autoscaling_target_utilization
}
}
resource "aws_appautoscaling_policy" "write" {
for_each = aws_appautoscaling_target.write
name = "${each.value.resource_id}-write-tracking"
policy_type = "TargetTrackingScaling"
resource_id = each.value.resource_id
scalable_dimension = each.value.scalable_dimension
service_namespace = each.value.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "DynamoDBWriteCapacityUtilization"
}
target_value = var.autoscaling_target_utilization
}
}
variables.tf
variable "name" {
description = "Name of the DynamoDB table."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9._-]{3,255}$", var.name))
error_message = "Table name must be 3-255 chars of letters, numbers, dot, dash, or underscore."
}
}
variable "billing_mode" {
description = "Billing mode: PROVISIONED or PAY_PER_REQUEST (on-demand)."
type = string
default = "PAY_PER_REQUEST"
validation {
condition = contains(["PROVISIONED", "PAY_PER_REQUEST"], var.billing_mode)
error_message = "billing_mode must be PROVISIONED or PAY_PER_REQUEST."
}
}
variable "hash_key" {
description = "Attribute name to use as the partition (hash) key."
type = string
}
variable "range_key" {
description = "Optional attribute name to use as the sort (range) key."
type = string
default = null
}
variable "attributes" {
description = "Attribute definitions for the table key and all index keys. type is S, N, or B."
type = list(object({
name = string
type = string
}))
validation {
condition = alltrue([for a in var.attributes : contains(["S", "N", "B"], a.type)])
error_message = "Each attribute type must be S (string), N (number), or B (binary)."
}
}
variable "global_secondary_indexes" {
description = "Global secondary indexes. Each GSI's hash_key/range_key must appear in var.attributes."
type = list(object({
name = string
hash_key = string
range_key = optional(string)
projection_type = string
non_key_attributes = optional(list(string))
read_capacity = optional(number)
write_capacity = optional(number)
}))
default = []
validation {
condition = alltrue([for g in var.global_secondary_indexes : contains(["ALL", "KEYS_ONLY", "INCLUDE"], g.projection_type)])
error_message = "GSI projection_type must be ALL, KEYS_ONLY, or INCLUDE."
}
}
variable "local_secondary_indexes" {
description = "Local secondary indexes. Requires a table range_key; each LSI range_key must appear in var.attributes."
type = list(object({
name = string
range_key = string
projection_type = string
non_key_attributes = optional(list(string))
}))
default = []
}
variable "ttl_attribute_name" {
description = "Name of the Number attribute holding the epoch expiry timestamp. Null disables TTL."
type = string
default = null
}
variable "stream_enabled" {
description = "Enable DynamoDB Streams for change data capture."
type = bool
default = false
}
variable "stream_view_type" {
description = "Stream view: KEYS_ONLY, NEW_IMAGE, OLD_IMAGE, or NEW_AND_OLD_IMAGES."
type = string
default = "NEW_AND_OLD_IMAGES"
validation {
condition = contains(["KEYS_ONLY", "NEW_IMAGE", "OLD_IMAGE", "NEW_AND_OLD_IMAGES"], var.stream_view_type)
error_message = "stream_view_type must be KEYS_ONLY, NEW_IMAGE, OLD_IMAGE, or NEW_AND_OLD_IMAGES."
}
}
variable "point_in_time_recovery_enabled" {
description = "Enable continuous backups / point-in-time recovery (35-day window)."
type = bool
default = true
}
variable "server_side_encryption_enabled" {
description = "Enable SSE with a customer-managed KMS key. When false, DynamoDB still encrypts with an AWS-owned key."
type = bool
default = true
}
variable "kms_key_arn" {
description = "ARN of the customer-managed KMS key for SSE. Required when server_side_encryption_enabled is true and you want a CMK; null uses the AWS-managed aws/dynamodb key."
type = string
default = null
}
variable "table_class" {
description = "Storage class: STANDARD or STANDARD_INFREQUENT_ACCESS."
type = string
default = "STANDARD"
validation {
condition = contains(["STANDARD", "STANDARD_INFREQUENT_ACCESS"], var.table_class)
error_message = "table_class must be STANDARD or STANDARD_INFREQUENT_ACCESS."
}
}
variable "deletion_protection_enabled" {
description = "Block accidental table deletion via the API/console."
type = bool
default = true
}
# --- Provisioned capacity + autoscaling (ignored in PAY_PER_REQUEST) -------
variable "read_capacity" {
description = "Base/minimum read capacity units (PROVISIONED mode)."
type = number
default = 5
}
variable "write_capacity" {
description = "Base/minimum write capacity units (PROVISIONED mode)."
type = number
default = 5
}
variable "autoscaling_enabled" {
description = "Attach target-tracking autoscaling to table + GSIs (PROVISIONED mode only)."
type = bool
default = true
}
variable "autoscaling_read_max_capacity" {
description = "Maximum read capacity units autoscaling may provision."
type = number
default = 100
}
variable "autoscaling_write_max_capacity" {
description = "Maximum write capacity units autoscaling may provision."
type = number
default = 100
}
variable "autoscaling_target_utilization" {
description = "Target consumed-capacity percentage for target tracking (1-90)."
type = number
default = 70
validation {
condition = var.autoscaling_target_utilization >= 1 && var.autoscaling_target_utilization <= 90
error_message = "autoscaling_target_utilization must be between 1 and 90."
}
}
variable "tags" {
description = "Tags applied to the table."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "Name/ID of the DynamoDB table."
value = aws_dynamodb_table.this.id
}
output "name" {
description = "Name of the DynamoDB table."
value = aws_dynamodb_table.this.name
}
output "arn" {
description = "ARN of the DynamoDB table (use in IAM policies)."
value = aws_dynamodb_table.this.arn
}
output "stream_arn" {
description = "ARN of the DynamoDB stream (null when streams are disabled)."
value = aws_dynamodb_table.this.stream_arn
}
output "stream_label" {
description = "Timestamp-based label of the stream (null when disabled)."
value = aws_dynamodb_table.this.stream_label
}
output "hash_key" {
description = "Partition key attribute name."
value = aws_dynamodb_table.this.hash_key
}
output "range_key" {
description = "Sort key attribute name (null if none)."
value = aws_dynamodb_table.this.range_key
}
output "gsi_arns" {
description = "Map of GSI name to its index ARN, for fine-grained IAM index permissions."
value = {
for idx in var.global_secondary_indexes :
idx.name => "${aws_dynamodb_table.this.arn}/index/${idx.name}"
}
}
How to use it
This example provisions a single-table-design orders table in PROVISIONED mode with autoscaling, one GSI for querying by customer, a TTL on expiresAt, streams feeding a Lambda, and a customer-managed KMS key. A downstream IAM policy then grants a Lambda read/write on the table and its GSI using the module outputs.
module "dynamodb_table" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-dynamodb?ref=v1.0.0"
name = "orders-prod"
billing_mode = "PROVISIONED"
hash_key = "PK"
range_key = "SK"
attributes = [
{ name = "PK", type = "S" },
{ name = "SK", type = "S" },
{ name = "GSI1PK", type = "S" },
{ name = "GSI1SK", type = "S" },
]
global_secondary_indexes = [
{
name = "GSI1"
hash_key = "GSI1PK"
range_key = "GSI1SK"
projection_type = "ALL"
},
]
# Auto-expire soft-deleted / staged orders.
ttl_attribute_name = "expiresAt"
# Stream change events to the projection Lambda (outbox / CDC).
stream_enabled = true
stream_view_type = "NEW_AND_OLD_IMAGES"
# Compliance: customer-managed CMK + PITR + deletion protection.
server_side_encryption_enabled = true
kms_key_arn = aws_kms_key.dynamodb.arn
point_in_time_recovery_enabled = true
deletion_protection_enabled = true
# Capacity floor + autoscaling ceiling.
read_capacity = 10
write_capacity = 10
autoscaling_enabled = true
autoscaling_read_max_capacity = 400
autoscaling_write_max_capacity = 400
autoscaling_target_utilization = 70
tags = {
Environment = "prod"
Team = "commerce"
CostCenter = "cc-1042"
}
}
# Downstream: grant a Lambda least-privilege access using module outputs.
resource "aws_iam_role_policy" "orders_lambda_ddb" {
name = "orders-ddb-access"
role = aws_iam_role.orders_processor.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:Query",
"dynamodb:BatchWriteItem",
]
Resource = [
module.dynamodb_table.arn,
module.dynamodb_table.gsi_arns["GSI1"],
]
}]
})
}
# Downstream: wire the table stream into the projection Lambda.
resource "aws_lambda_event_source_mapping" "orders_stream" {
event_source_arn = module.dynamodb_table.stream_arn
function_name = aws_lambda_function.projector.arn
starting_position = "LATEST"
batch_size = 100
}
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/dynamodb/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-dynamodb?ref=v1.0.0"
}
inputs = {
name = "..."
hash_key = "..."
attributes = ["...", "..."]
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/dynamodb && 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 | Name of the DynamoDB table (3-255 chars). |
billing_mode |
string |
"PAY_PER_REQUEST" |
no | PROVISIONED or PAY_PER_REQUEST (on-demand). |
hash_key |
string |
— | yes | Partition (hash) key attribute name. |
range_key |
string |
null |
no | Sort (range) key attribute name. |
attributes |
list(object({name, type})) |
— | yes | Key attribute definitions for table + indexes; type is S/N/B. |
global_secondary_indexes |
list(object(...)) |
[] |
no | GSI definitions (name, keys, projection, optional per-index capacity). |
local_secondary_indexes |
list(object(...)) |
[] |
no | LSI definitions; requires a table range_key. |
ttl_attribute_name |
string |
null |
no | Number attribute holding epoch expiry; null disables TTL. |
stream_enabled |
bool |
false |
no | Enable DynamoDB Streams. |
stream_view_type |
string |
"NEW_AND_OLD_IMAGES" |
no | Stream record content when streams are enabled. |
point_in_time_recovery_enabled |
bool |
true |
no | Enable continuous backups / PITR (35-day window). |
server_side_encryption_enabled |
bool |
true |
no | Enable SSE with a CMK. When false, an AWS-owned key is still used. |
kms_key_arn |
string |
null |
no | Customer-managed KMS key ARN; null uses aws/dynamodb. |
table_class |
string |
"STANDARD" |
no | STANDARD or STANDARD_INFREQUENT_ACCESS. |
deletion_protection_enabled |
bool |
true |
no | Block accidental table deletion. |
read_capacity |
number |
5 |
no | Base/min read capacity units (PROVISIONED). |
write_capacity |
number |
5 |
no | Base/min write capacity units (PROVISIONED). |
autoscaling_enabled |
bool |
true |
no | Attach target-tracking autoscaling to table + GSIs (PROVISIONED only). |
autoscaling_read_max_capacity |
number |
100 |
no | Max read capacity autoscaling may provision. |
autoscaling_write_max_capacity |
number |
100 |
no | Max write capacity autoscaling may provision. |
autoscaling_target_utilization |
number |
70 |
no | Target consumed-capacity percentage (1-90). |
tags |
map(string) |
{} |
no | Tags applied to the table. |
Outputs
| Name | Description |
|---|---|
id |
Name/ID of the DynamoDB table. |
name |
Name of the DynamoDB table. |
arn |
ARN of the table, for use in IAM policies. |
stream_arn |
ARN of the DynamoDB stream (null when streams are disabled). |
stream_label |
Timestamp-based stream label (null when disabled). |
hash_key |
Partition key attribute name. |
range_key |
Sort key attribute name (null if none). |
gsi_arns |
Map of GSI name to its index ARN, for fine-grained IAM index permissions. |
Enterprise scenario
A commerce platform runs an event-driven order pipeline on a single orders-prod table using single-table design: orders, line items, and customer-order lookups share the table, with GSI1 serving the “all orders for a customer, newest first” access pattern. DynamoDB Streams (NEW_AND_OLD_IMAGES) feed a projection Lambda that maintains a read-optimized view and publishes domain events to EventBridge — an in-table outbox that guarantees no order change is lost. Provisioned capacity with target-tracking autoscaling absorbs the predictable 10x traffic surge during flash sales while keeping steady-state cost low, and PITR plus a customer-managed CMK satisfy the retailer’s PCI-adjacent restore-SLA and encryption requirements.
Best practices
- Keep PITR and deletion protection on for prod. This module defaults both to
true; PITR gives you 35-day per-second restore and deletion protection prevents a fat-fingeredterraform destroyor console delete from wiping a system-of-record table. Layerprevent_destroy = truein the consuming root module for irreplaceable tables. - Right-size the billing mode per workload. Use
PAY_PER_REQUESTfor spiky, unpredictable, or low-traffic tables (no capacity planning, no idle cost); switch toPROVISIONED+ autoscaling only when sustained, predictable traffic makes reserved/provisioned cheaper. You can changebilling_modein place once every 24 hours. - Encrypt with a customer-managed CMK and scope IAM tightly. Pass
kms_key_arnso key rotation, grants, and audit live under your control, and grant consumers only the actions and resources they need — use thearnandgsi_arnsoutputs to writeResource-scoped, index-aware policies rather thandynamodb:*on*. - Model attributes and GSIs deliberately. Only declare attributes that are table or index keys (DynamoDB is schemaless otherwise), prefer
KEYS_ONLY/INCLUDEprojections overALLto cut GSI storage and write cost, and remember each GSI write consumes its own capacity — over-projecting is a silent cost multiplier. - Use TTL to control storage growth, not for precise scheduling. Point
ttl_attribute_nameat an epoch-seconds attribute for cheap auto-expiry of staged/soft-deleted items; deletes are free but asynchronous (can lag up to ~48 hours), so don’t rely on TTL for correctness-critical timing. - Adopt a consistent naming and tagging convention. Suffix table names with the environment (
orders-prod,orders-staging) for clear blast-radius separation across accounts, and passtagswithEnvironment,Team, andCostCenterso DynamoDB spend is attributable in Cost Explorer.