top of page

Creating Terraform DynamoDB Tables and Configurations

  • backlinksindiit
  • 7 days ago
  • 6 min read

Clicking through AWS console creating DynamoDB tables wastes time and creates inconsistency. Team members configure tables differently. Production and staging drift apart. Documentation becomes outdated immediately. Creating Terraform DynamoDB tables with infrastructure-as-code solves these problems through version-controlled, repeatable configurations.

Terraform Project Structure for DynamoDB

Organize Terraform files logically for maintainability:

terraform/ ├── main.tf # Primary resources ├── variables.tf # Input variables ├── outputs.tf # Output values ├── backend.tf # State configuration └── modules/ └── dynamodb/ ├── main.tf ├── variables.tf └── outputs.tf

This structure separates concerns. Variables define configuration inputs. Outputs expose table ARNs and names for other resources. Modules enable reusable table patterns across environments.

Choosing the Right Partition Key Design

Partition keys determine DynamoDB performance. Poor choices cause hot partitions where most traffic hits one physical server. Good choices distribute load evenly.

Bad partition key examples:

  • status field (most records have "active")

  • region field (uneven geographic distribution)

  • timestamp day (all today's records on same partition)

Good partition key examples:

  • userId (high cardinality, even distribution)

  • orderId (unique per order)

  • Composite: customerId#productId (combines multiple attributes)

Real-world table designs with proper keys:

# E-commerce Orders Table resource "aws_dynamodb_table" "orders" { name = "orders-${var.environment}" billing_mode = "PAY_PER_REQUEST" hash_key = "orderId" range_key = "createdAt" attribute { name = "orderId" type = "S" } attribute { name = "createdAt" type = "N" } attribute { name = "customerId" type = "S" } attribute { name = "orderStatus" type = "S" } global_secondary_index { name = "CustomerOrdersIndex" hash_key = "customerId" range_key = "createdAt" projection_type = "ALL" } global_secondary_index { name = "StatusIndex" hash_key = "orderStatus" range_key = "createdAt" projection_type = "KEYS_ONLY" } tags = { Environment = var.environment Purpose = "order-management" } } # User Analytics Table resource "aws_dynamodb_table" "analytics" { name = "user-analytics-${var.environment}" billing_mode = "PAY_PER_REQUEST" hash_key = "userId" range_key = "eventTimestamp" attribute { name = "userId" type = "S" } attribute { name = "eventTimestamp" type = "N" } attribute { name = "eventType" type = "S" } global_secondary_index { name = "EventTypeIndex" hash_key = "eventType" range_key = "eventTimestamp" projection_type = "INCLUDE" non_key_attributes = ["userId", "eventData"] } ttl { attribute_name = "expirationTime" enabled = true } tags = { Environment = var.environment DataRetention = "90-days" } }

Teams building applications for mobile app development houston clients benefit from TTL configurations automatically deleting old analytics data, reducing storage costs without manual cleanup scripts.

Pay-Per-Request vs Provisioned Capacity: The Real Math

Choosing billing modes impacts costs significantly. Here's actual pricing breakdown (us-east-1, 2025 rates):

Pay-Per-Request:

  • Write: $1.25 per million requests

  • Read: $0.25 per million requests

Provisioned Capacity:

  • Write: $0.00065 per WCU-hour ($0.47 per WCU-month)

  • Read: $0.00013 per RCU-hour ($0.09 per RCU-month)

Cost comparison at different scales:

Provisioned becomes cost-effective above 50 million combined operations monthly. Below that threshold, pay-per-request simplifies operations without capacity planning overhead.

Implementing Autoscaling for Provisioned Tables

Provisioned capacity requires autoscaling preventing throttling during traffic spikes:

resource "aws_dynamodb_table" "high_traffic" { name = "high-traffic-table" billing_mode = "PROVISIONED" read_capacity = 10 write_capacity = 10 hash_key = "id" attribute { name = "id" type = "S" } tags = { Environment = "production" } } resource "aws_appautoscaling_target" "read_target" { max_capacity = 500 min_capacity = 10 resource_id = "table/${aws_dynamodb_table.high_traffic.name}" scalable_dimension = "dynamodb:table:ReadCapacityUnits" service_namespace = "dynamodb" } resource "aws_appautoscaling_target" "write_target" { max_capacity = 500 min_capacity = 10 resource_id = "table/${aws_dynamodb_table.high_traffic.name}" scalable_dimension = "dynamodb:table:WriteCapacityUnits" service_namespace = "dynamodb" } resource "aws_appautoscaling_policy" "read_policy" { name = "DynamoDBReadCapacityUtilization:${aws_appautoscaling_target.read_target.resource_id}" policy_type = "TargetTrackingScaling" resource_id = aws_appautoscaling_target.read_target.resource_id scalable_dimension = aws_appautoscaling_target.read_target.scalable_dimension service_namespace = aws_appautoscaling_target.read_target.service_namespace target_tracking_scaling_policy_configuration { predefined_metric_specification { predefined_metric_type = "DynamoDBReadCapacityUtilization" } target_value = 70.0 scale_in_cooldown = 60 scale_out_cooldown = 60 } } resource "aws_appautoscaling_policy" "write_policy" { name = "DynamoDBWriteCapacityUtilization:${aws_appautoscaling_target.write_target.resource_id}" policy_type = "TargetTrackingScaling" resource_id = aws_appautoscaling_target.write_target.resource_id scalable_dimension = aws_appautoscaling_target.write_target.scalable_dimension service_namespace = aws_appautoscaling_target.write_target.service_namespace target_tracking_scaling_policy_configuration { predefined_metric_specification { predefined_metric_type = "DynamoDBWriteCapacityUtilization" } target_value = 70.0 scale_in_cooldown = 60 scale_out_cooldown = 60 } }

The target_value = 70.0 maintains 70% utilization. When actual usage exceeds 70%, autoscaling adds capacity. Below 70% for sustained periods, capacity decreases. Cooldown periods prevent rapid scaling oscillations causing cost spikes.

Terraform State Locking with DynamoDB

DynamoDB provides distributed locking for Terraform state preventing concurrent modifications:

# backend.tf terraform { backend "s3" { bucket = "company-terraform-state" key = "production/infrastructure.tfstate" region = "us-east-1" encrypt = true dynamodb_table = "terraform-state-locks" kms_key_id = "arn:aws:kms:us-east-1:123456789:key/abc-123" } } # State lock table resource "aws_dynamodb_table" "terraform_locks" { name = "terraform-state-locks" billing_mode = "PAY_PER_REQUEST" hash_key = "LockID" attribute { name = "LockID" type = "S" } point_in_time_recovery { enabled = true } tags = { Purpose = "terraform-state-locking" Critical = "true" } }

When Terraform starts, it writes a lock entry. Concurrent runs wait for lock release. Crashes leave stale locks requiring manual removal via AWS console or CLI: aws dynamodb delete-item --table-name terraform-state-locks --key '{"LockID":{"S":"company-terraform-state/production/infrastructure.tfstate-md5"}}'

Production Security: Encryption and Backups

Production tables require encryption at rest and point-in-time recovery:

resource "aws_kms_key" "dynamodb" { description = "DynamoDB encryption key" deletion_window_in_days = 30 enable_key_rotation = true tags = { Purpose = "dynamodb-encryption" } } resource "aws_kms_alias" "dynamodb" { name = "alias/dynamodb-${var.environment}" target_key_id = aws_kms_key.dynamodb.key_id } resource "aws_dynamodb_table" "production_data" { name = "production-data" billing_mode = "PAY_PER_REQUEST" hash_key = "id" attribute { name = "id" type = "S" } server_side_encryption { enabled = true kms_key_arn = aws_kms_key.dynamodb.arn } point_in_time_recovery { enabled = true } lifecycle { prevent_destroy = true } tags = { Environment = "production" Backup = "required" Compliance = "pci-dss" } }

Point-in-time recovery costs $0.20 per GB-month but enables restoration to any second within the last 35 days. Customer-managed KMS keys add $1/month per key plus API request charges but satisfy compliance requirements requiring customer-controlled encryption.

Variables and Modules for Reusable Configuration

Extract common patterns into modules preventing duplication:

# modules/dynamodb/variables.tf variable "table_name" { description = "DynamoDB table name" type = string } variable "hash_key" { description = "Partition key attribute" type = string } variable "range_key" { description = "Sort key attribute (optional)" type = string default = null } variable "billing_mode" { description = "Billing mode: PAY_PER_REQUEST or PROVISIONED" type = string default = "PAY_PER_REQUEST" } variable "attributes" { description = "List of attribute definitions" type = list(object({ name = string type = string })) } variable "global_secondary_indexes" { description = "GSI configurations" type = list(object({ name = string hash_key = string range_key = string projection_type = string non_key_attributes = list(string) })) default = [] } # modules/dynamodb/main.tf resource "aws_dynamodb_table" "this" { name = var.table_name billing_mode = var.billing_mode hash_key = var.hash_key range_key = var.range_key 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 = global_secondary_index.value.range_key projection_type = global_secondary_index.value.projection_type non_key_attributes = global_secondary_index.value.non_key_attributes } } } # Usage module "users_table" { source = "./modules/dynamodb" table_name = "users-production" billing_mode = "PAY_PER_REQUEST" hash_key = "userId" range_key = "createdAt" attributes = [ { name = "userId", type = "S" }, { name = "createdAt", type = "N" }, { name = "email", type = "S" } ] global_secondary_indexes = [ { name = "EmailIndex" hash_key = "email" range_key = null projection_type = "ALL" non_key_attributes = [] } ] }

Troubleshooting Terraform DynamoDB Issues

Issue: "Error creating DynamoDB Table: ResourceInUseException"

Table name already exists. Check AWS console or use terraform import:

terraform import aws_dynamodb_table.users users-production

Issue: "ValidationException: One or more parameter values were invalid"

Attribute definitions only include keys and index attributes. DynamoDB is schemaless - other attributes get added dynamically without declaration.

Issue: Terraform wants to replace table

Check for changes in hash_key or range_key. These require table recreation with data loss. Use terraform plan before applying to review changes.

Issue: "Error enabling streams: ResourceNotFoundException"

Streams enable only after table creation completes. Add depends_on if Lambda triggers depend on streams.

Implementation Checklist:

  1. Organize Terraform files into logical structure with modules

  2. Choose high-cardinality partition keys for even distribution

  3. Design GSIs based on actual query patterns, not speculation

  4. Calculate costs comparing pay-per-request vs provisioned for your scale

  5. Implement autoscaling for provisioned tables with 70% target utilization

  6. Enable point-in-time recovery for production tables

  7. Use customer-managed KMS keys when compliance requires

  8. Create DynamoDB table for Terraform state locking

  9. Add lifecycle { prevent_destroy = true } protecting production tables

  10. Extract common patterns into reusable Terraform modules

  11. Use terraform import for existing tables before managing with Terraform

  12. Monitor CloudWatch metrics: ConsumedCapacity, UserErrors, SystemErrors

Creating Terraform DynamoDB tables through infrastructure-as-code ensures consistency across environments, enables version control of database configurations, and prevents manual configuration errors. The initial learning curve pays dividends through reproducible, documented infrastructure.

 
 
 

Recent Posts

See All

Comments


bottom of page