# AWS DynamoDB Tables with Terraform: Keys, Indexes, TTL and Autoscaling

> Create AWS DynamoDB tables with Terraform or OpenTofu: partition and sort keys, global secondary indexes, on-demand or provisioned capacity, TTL, backups and encryption.

- Source: https://www.itwonderlab.com/terraform-aws-dynamodb/
- Published: 2026-04-19
- Updated: 2026-04-19
- Author: Javier Ruiz Jiménez (https://www.javierruizjimenez.com/)
- Site: IT Wonder Lab (https://www.itwonderlab.com/)

---

## A DynamoDB table in Terraform

[Amazon DynamoDB](https://www.itwonderlab.com/aws-dynamodb/) is a managed NoSQL key-value and document database. You choose the key schema first, because it defines how you can query the data, then the capacity mode.

### A table with partition and sort key

```hcl title="dynamodb.tf"
resource "aws_dynamodb_table" "orders" {
  name         = "ditwl-orders"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "customer_id"
  range_key    = "order_id"

  attribute {
    name = "customer_id"
    type = "S"
  }

  attribute {
    name = "order_id"
    type = "S"
  }

  point_in_time_recovery {
    enabled = true
  }

  server_side_encryption {
    enabled     = true
    kms_key_arn = aws_kms_key.dynamodb.arn
  }

  deletion_protection_enabled = true

  tags = local.common_tags
}

resource "aws_kms_key" "dynamodb" {
  description         = "DynamoDB table encryption"
  enable_key_rotation = true
}
```

- `hash_key` is the **partition key**: items with the same value are stored together, and every query must specify it.
- `range_key` is the optional **sort key**, which orders the items inside a partition and allows range queries.
- You declare as `attribute` only the attributes that are part of a key or an index. DynamoDB does not need a schema for the rest.
- Types are `S` (string), `N` (number) and `B` (binary).
- Point-in-time recovery (PITR) allows restoring to any second of the last 35 days. Enable it for data you care about.
- Omit `kms_key_arn` to use the AWS-owned key at no cost. Use your [KMS](https://www.itwonderlab.com/aws-kms/) key for control and audit.

### Capacity modes

| Mode | Use | Terraform |
|---|---|---|
| On-demand | Unknown or spiky traffic, new applications | `billing_mode = "PAY_PER_REQUEST"` |
| Provisioned | Predictable traffic, lower unit cost | `billing_mode = "PROVISIONED"` with `read_capacity` and `write_capacity` |

On-demand is the simplest and the right start for most workloads.

### Global secondary index

An index lets you query by other attributes:

```hcl title="dynamodb.tf"
resource "aws_dynamodb_table" "orders" {
  # ... same arguments as above

  attribute {
    name = "status"
    type = "S"
  }

  attribute {
    name = "created_at"
    type = "S"
  }

  global_secondary_index {
    name            = "status-created_at-index"
    hash_key        = "status"
    range_key       = "created_at"
    projection_type = "ALL"
  }
}
```

Projection `ALL` copies every attribute into the index (more storage and write cost). Use `KEYS_ONLY` or `INCLUDE` when you only need some of them. In provisioned mode, an index needs its own capacity.

### Time to live (TTL)

Delete expired items automatically, at no cost:

```hcl title="dynamodb.tf"
  ttl {
    attribute_name = "expires_at"   # epoch seconds in a number attribute
    enabled        = true
  }
```

### Autoscaling for provisioned tables

```hcl title="autoscaling.tf"
resource "aws_appautoscaling_target" "read" {
  service_namespace  = "dynamodb"
  resource_id        = "table/${aws_dynamodb_table.orders.name}"
  scalable_dimension = "dynamodb:table:ReadCapacityUnits"
  min_capacity       = 5
  max_capacity       = 100
}

resource "aws_appautoscaling_policy" "read" {
  name               = "dynamodb-read-scaling"
  service_namespace  = aws_appautoscaling_target.read.service_namespace
  resource_id        = aws_appautoscaling_target.read.resource_id
  scalable_dimension = aws_appautoscaling_target.read.scalable_dimension
  policy_type        = "TargetTrackingScaling"

  target_tracking_scaling_policy_configuration {
    target_value = 70

    predefined_metric_specification {
      predefined_metric_type = "DynamoDBReadCapacityUtilization"
    }
  }
}
```

If you use autoscaling, ignore the capacity that it changes: `lifecycle { ignore_changes = [read_capacity, write_capacity] }` in the table ([lifecycle](https://www.itwonderlab.com/terraform-lifecycle-meta-argument/)).

### Allow an application to use it

```hcl title="iam.tf"
data "aws_iam_policy_document" "orders_access" {
  statement {
    actions = [
      "dynamodb:GetItem",
      "dynamodb:PutItem",
      "dynamodb:UpdateItem",
      "dynamodb:Query",
    ]
    resources = [
      aws_dynamodb_table.orders.arn,
      "${aws_dynamodb_table.orders.arn}/index/*",
    ]
  }
}
```

Attach it to the [role of a Lambda function](https://www.itwonderlab.com/terraform-aws-lambda-api-gateway/) or of an EC2 instance. See [IAM roles](https://www.itwonderlab.com/aws-terraform-tutorial-aws-iam-roles-policies/).

### Design tips

- Model your **access patterns** first, then choose the keys. DynamoDB is not a relational database: there are no joins.
- Choose a partition key with many different values to spread the load. A key such as `status` concentrates traffic ("hot partition").
- Use `Query` instead of `Scan`, which reads the whole table.
- Keep items under 400 KB.

For relational workloads use [RDS](https://www.itwonderlab.com/aws-terraform-tutorial-aws-rds/) or [Aurora](https://www.itwonderlab.com/aws-aurora/).
