IaC AWS

Terraform Module: AWS DynamoDB Table — production-ready single-table storage with autoscaling, PITR, and encryption

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

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 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/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

TerraformAWSDynamoDB TableModuleIaC
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