IaC AWS

Terraform Module: AWS Keyspaces (Cassandra) — serverless CQL tables with PITR, TTL, and customer-managed encryption

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for aws_keyspaces_keyspace and aws_keyspaces_table covering CQL schema definitions, ON_DEMAND vs PROVISIONED throughput, point-in-time recovery, default TTL, 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 "keyspaces" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-keyspaces?ref=v1.0.0"

  keyspace_name  = "..."           # Keyspace (CQL namespace) name, 1-48 chars.
  table_name     = "..."           # Table name within the keyspace, 1-48 chars.
  columns        = ["...", "..."]  # All columns and their CQL types (e.g. `text`, `uuid`, `…
  partition_keys = ["...", "..."]  # Ordered partition key column names; each must be in `co…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon Keyspaces (for Apache Cassandra) is a serverless, fully managed, wire-compatible Cassandra service. You talk to it with the same CQL drivers you already use, but there are no nodes, no ring, no repair, and no compaction to operate — Keyspaces handles partitioning, replication across three Availability Zones, and storage growth transparently, and it scales tables up and down based on throughput. The two building blocks are a keyspace (the logical namespace, like a database) and a table inside it (the schema with a partition key, optional clustering columns, and regular columns).

The catch is that a production-grade Keyspaces table is much more than a partition key. In practice you need a fully specified CQL schema (schema_definition with every column and its CQL type, the partition key columns, and ordered clustering keys with ASC/DESC), a capacity_specification that picks PAY_PER_REQUEST (on-demand) or PROVISIONED read/write capacity units, point-in-time recovery so you can restore to any second in the last 35 days, a default per-row ttl to auto-expire data, client-side timestamps for last-writer-wins conflict resolution, and encryption_specification with a customer-managed KMS key. Writing all of that by hand for every table is repetitive and easy to get subtly wrong — listing a clustering key that isn’t in the partition or clustering set, forgetting that PITR must be explicitly enabled, or shipping a table with the default AWS-owned key when compliance demands a CMK.

This module wraps aws_keyspaces_keyspace plus aws_keyspaces_table behind a clean, var-driven interface. You describe the columns, partition key, and clustering keys once; the module enforces sane production defaults (PITR on, customer-managed-key-ready encryption, client-side timestamps on) and exposes the keyspace name, table name, and table ARN as outputs for downstream IAM policies, CloudWatch alarms, and application configuration.

When to use it

Reach for a different approach if your access pattern is simple key-value or single-table-design document storage with no clustering columns — DynamoDB is usually cheaper and simpler there. Keyspaces shines when you specifically want CQL semantics, wide rows with clustering order, and Cassandra driver compatibility.

Module structure

terraform-module-aws-keyspaces/
├── versions.tf      # provider + Terraform version constraints
├── main.tf          # aws_keyspaces_keyspace + aws_keyspaces_table
├── variables.tf     # input variables with validation
└── outputs.tf       # keyspace name, table name/arn, key attributes

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Provisioned throughput only applies when capacity mode is PROVISIONED.
  provisioned = var.capacity_mode == "PROVISIONED"

  # Clustering keys are emitted in declared order; ordinal preserves it.
  clustering_keys = {
    for idx, ck in var.clustering_keys : ck.name => {
      order_by = ck.order_by
      ordinal  = idx
    }
  }
}

resource "aws_keyspaces_keyspace" "this" {
  name = var.keyspace_name

  tags = merge(var.tags, { Name = var.keyspace_name })
}

resource "aws_keyspaces_table" "this" {
  keyspace_name = aws_keyspaces_keyspace.this.name
  table_name    = var.table_name

  # ---- CQL schema -------------------------------------------------------
  schema_definition {
    # Every column in the table and its CQL data type.
    dynamic "column" {
      for_each = var.columns
      content {
        name = column.value.name
        type = column.value.type
      }
    }

    # Partition key columns (one or more, ordered).
    dynamic "partition_key" {
      for_each = var.partition_keys
      content {
        name = partition_key.value
      }
    }

    # Clustering keys define on-disk sort order within a partition.
    dynamic "clustering_key" {
      for_each = var.clustering_keys
      content {
        name     = clustering_key.value.name
        order_by = clustering_key.value.order_by
      }
    }

    # Static columns share one value across all rows of a partition.
    dynamic "static_column" {
      for_each = var.static_columns
      content {
        name = static_column.value
      }
    }
  }

  # ---- Capacity: on-demand vs provisioned ------------------------------
  capacity_specification {
    throughput_mode = var.capacity_mode

    # Only meaningful (and only allowed) in PROVISIONED mode.
    read_capacity_units  = local.provisioned ? var.read_capacity_units : null
    write_capacity_units = local.provisioned ? var.write_capacity_units : null
  }

  # ---- Encryption at rest ----------------------------------------------
  encryption_specification {
    # CUSTOMER_MANAGED_KMS_KEY requires kms_key_identifier; AWS_OWNED_KMS_KEY does not.
    type               = var.kms_key_identifier != null ? "CUSTOMER_MANAGED_KMS_KEY" : "AWS_OWNED_KMS_KEY"
    kms_key_identifier = var.kms_key_identifier
  }

  # ---- Point-in-time recovery (35-day continuous backup) ---------------
  point_in_time_recovery {
    status = var.point_in_time_recovery_enabled ? "ENABLED" : "DISABLED"
  }

  # ---- Default per-row TTL ---------------------------------------------
  # The ttl block enables the TTL feature on the table; default_time_to_live
  # sets the default expiry (seconds) applied to rows that don't override it.
  dynamic "ttl" {
    for_each = var.ttl_enabled ? [1] : []
    content {
      status = "ENABLED"
    }
  }

  default_time_to_live = var.ttl_enabled ? var.default_time_to_live : null

  # ---- Client-side timestamps (last-writer-wins conflict resolution) ---
  client_side_timestamps {
    status = var.client_side_timestamps_enabled ? "ENABLED" : "DISABLED"
  }

  tags = merge(var.tags, { Name = "${var.keyspace_name}.${var.table_name}" })
}

variables.tf

variable "keyspace_name" {
  description = "Name of the Keyspaces keyspace (the CQL namespace)."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9_]{1,48}$", var.keyspace_name))
    error_message = "keyspace_name must be 1-48 chars of letters, numbers, or underscore."
  }
}

variable "table_name" {
  description = "Name of the table within the keyspace."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9_]{1,48}$", var.table_name))
    error_message = "table_name must be 1-48 chars of letters, numbers, or underscore."
  }
}

variable "columns" {
  description = "All columns in the table. type is a CQL type, e.g. text, int, bigint, uuid, timeuuid, timestamp, boolean, decimal, blob, or a collection like list<text>."
  type = list(object({
    name = string
    type = string
  }))

  validation {
    condition     = length(var.columns) > 0
    error_message = "At least one column must be defined."
  }
}

variable "partition_keys" {
  description = "Ordered list of column names forming the partition key. Each must appear in var.columns."
  type        = list(string)

  validation {
    condition     = length(var.partition_keys) > 0
    error_message = "At least one partition key column is required."
  }
}

variable "clustering_keys" {
  description = "Ordered list of clustering key columns defining on-disk sort order within a partition. Each name must appear in var.columns."
  type = list(object({
    name     = string
    order_by = string
  }))
  default = []

  validation {
    condition     = alltrue([for ck in var.clustering_keys : contains(["ASC", "DESC"], ck.order_by)])
    error_message = "Each clustering key order_by must be ASC or DESC."
  }
}

variable "static_columns" {
  description = "Column names that are static (one shared value per partition). Each must appear in var.columns and must not be a key column."
  type        = list(string)
  default     = []
}

variable "capacity_mode" {
  description = "Throughput mode: PAY_PER_REQUEST (on-demand) or PROVISIONED."
  type        = string
  default     = "PAY_PER_REQUEST"

  validation {
    condition     = contains(["PAY_PER_REQUEST", "PROVISIONED"], var.capacity_mode)
    error_message = "capacity_mode must be PAY_PER_REQUEST or PROVISIONED."
  }
}

variable "read_capacity_units" {
  description = "Provisioned read capacity units (RCUs). Used only when capacity_mode is PROVISIONED."
  type        = number
  default     = 10

  validation {
    condition     = var.read_capacity_units >= 1
    error_message = "read_capacity_units must be at least 1."
  }
}

variable "write_capacity_units" {
  description = "Provisioned write capacity units (WCUs). Used only when capacity_mode is PROVISIONED."
  type        = number
  default     = 10

  validation {
    condition     = var.write_capacity_units >= 1
    error_message = "write_capacity_units must be at least 1."
  }
}

variable "kms_key_identifier" {
  description = "ARN of the customer-managed KMS key for encryption at rest. Null uses the AWS-owned key (AWS_OWNED_KMS_KEY)."
  type        = string
  default     = null
}

variable "point_in_time_recovery_enabled" {
  description = "Enable point-in-time recovery (continuous backup, 35-day restore window)."
  type        = bool
  default     = true
}

variable "ttl_enabled" {
  description = "Enable the Time to Live feature on the table so rows can expire automatically."
  type        = bool
  default     = false
}

variable "default_time_to_live" {
  description = "Default row TTL in seconds, applied when ttl_enabled is true. Max 630720000 (20 years)."
  type        = number
  default     = null

  validation {
    condition     = var.default_time_to_live == null || (var.default_time_to_live >= 0 && var.default_time_to_live <= 630720000)
    error_message = "default_time_to_live must be between 0 and 630720000 seconds (20 years)."
  }
}

variable "client_side_timestamps_enabled" {
  description = "Enable client-side timestamps for last-writer-wins conflict resolution. Cannot be disabled once enabled."
  type        = bool
  default     = true
}

variable "tags" {
  description = "Tags applied to the keyspace and table."
  type        = map(string)
  default     = {}
}

outputs.tf

output "keyspace_name" {
  description = "Name of the keyspace."
  value       = aws_keyspaces_keyspace.this.name
}

output "keyspace_arn" {
  description = "ARN of the keyspace (use in IAM policies scoped to the namespace)."
  value       = aws_keyspaces_keyspace.this.arn
}

output "table_name" {
  description = "Name of the table within the keyspace."
  value       = aws_keyspaces_table.this.table_name
}

output "table_id" {
  description = "Terraform resource ID of the table (keyspace_name/table_name)."
  value       = aws_keyspaces_table.this.id
}

output "table_arn" {
  description = "ARN of the table (use in IAM policies and CloudWatch alarms)."
  value       = aws_keyspaces_table.this.arn
}

output "capacity_mode" {
  description = "Throughput mode in effect (PAY_PER_REQUEST or PROVISIONED)."
  value       = aws_keyspaces_table.this.capacity_specification[0].throughput_mode
}

output "partition_keys" {
  description = "Ordered partition key column names."
  value       = var.partition_keys
}

output "clustering_keys" {
  description = "Ordered clustering key column names with their sort order."
  value       = var.clustering_keys
}

How to use it

This example provisions a telemetry keyspace with a wide-column device_readings table for IoT time-series: partitioned by device_id, clustered by reading_time DESC so the newest reading is first, with a 90-day default TTL, on-demand capacity, PITR, and a customer-managed KMS key. A downstream IAM policy then grants an ingestion role least-privilege write access using the module’s table ARN.

module "keyspaces_cassandra_telemetry" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-keyspaces?ref=v1.0.0"

  keyspace_name = "telemetry"
  table_name    = "device_readings"

  columns = [
    { name = "device_id",    type = "uuid" },
    { name = "reading_time", type = "timestamp" },
    { name = "temperature",  type = "decimal" },
    { name = "humidity",     type = "decimal" },
    { name = "firmware",     type = "text" },
    { name = "metadata",     type = "map<text, text>" },
  ]

  # device_id partitions the data; reading_time sorts newest-first per device.
  partition_keys = ["device_id"]
  clustering_keys = [
    { name = "reading_time", order_by = "DESC" },
  ]

  # firmware is the same for every reading from a device -> static column.
  static_columns = ["firmware"]

  # Spiky ingestion -> serverless on-demand throughput.
  capacity_mode = "PAY_PER_REQUEST"

  # Retain raw readings for 90 days, then auto-expire.
  ttl_enabled          = true
  default_time_to_live = 7776000 # 90 days in seconds

  # Compliance: customer-managed CMK + PITR + client-side timestamps.
  kms_key_identifier             = aws_kms_key.keyspaces.arn
  point_in_time_recovery_enabled = true
  client_side_timestamps_enabled = true

  tags = {
    Environment = "prod"
    Team        = "iot-platform"
    CostCenter  = "cc-2207"
  }
}

# Downstream: grant the ingestion role least-privilege write access
# using the module's table ARN.
resource "aws_iam_role_policy" "telemetry_ingest" {
  name = "telemetry-keyspaces-write"
  role = aws_iam_role.telemetry_ingestor.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "cassandra:Select",
        "cassandra:Modify",
      ]
      Resource = [
        module.keyspaces_cassandra_telemetry.table_arn,
      ]
    }]
  })
}

# Downstream: alarm on user errors against the table using its name.
resource "aws_cloudwatch_metric_alarm" "telemetry_user_errors" {
  alarm_name          = "keyspaces-${module.keyspaces_cassandra_telemetry.table_name}-user-errors"
  namespace           = "AWS/Cassandra"
  metric_name         = "UserErrors"
  comparison_operator = "GreaterThanThreshold"
  threshold           = 0
  evaluation_periods  = 1
  period              = 60
  statistic           = "Sum"

  dimensions = {
    Keyspace   = module.keyspaces_cassandra_telemetry.keyspace_name
    TableName  = module.keyspaces_cassandra_telemetry.table_name
  }
}

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/keyspaces/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-keyspaces?ref=v1.0.0"
}

inputs = {
  keyspace_name = "..."
  table_name = "..."
  columns = ["...", "..."]
  partition_keys = ["...", "..."]
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/keyspaces && 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
keyspace_name string yes Keyspace (CQL namespace) name, 1-48 chars.
table_name string yes Table name within the keyspace, 1-48 chars.
columns list(object({name, type})) yes All columns and their CQL types (e.g. text, uuid, timestamp, map<text,text>).
partition_keys list(string) yes Ordered partition key column names; each must be in columns.
clustering_keys list(object({name, order_by})) [] no Ordered clustering keys with ASC/DESC sort order.
static_columns list(string) [] no Columns whose value is shared across all rows of a partition.
capacity_mode string "PAY_PER_REQUEST" no PAY_PER_REQUEST (on-demand) or PROVISIONED.
read_capacity_units number 10 no Provisioned RCUs (used only in PROVISIONED mode).
write_capacity_units number 10 no Provisioned WCUs (used only in PROVISIONED mode).
kms_key_identifier string null no Customer-managed KMS key ARN; null uses the AWS-owned key.
point_in_time_recovery_enabled bool true no Enable PITR (35-day continuous backup).
ttl_enabled bool false no Enable the table TTL feature for automatic row expiry.
default_time_to_live number null no Default row TTL in seconds (0–630720000) when ttl_enabled is true.
client_side_timestamps_enabled bool true no Enable client-side timestamps (last-writer-wins). Cannot be disabled later.
tags map(string) {} no Tags applied to the keyspace and table.

Outputs

Name Description
keyspace_name Name of the keyspace.
keyspace_arn ARN of the keyspace, for namespace-scoped IAM policies.
table_name Name of the table within the keyspace.
table_id Terraform resource ID of the table (keyspace_name/table_name).
table_arn ARN of the table, for IAM policies and CloudWatch alarms.
capacity_mode Throughput mode in effect (PAY_PER_REQUEST or PROVISIONED).
partition_keys Ordered partition key column names.
clustering_keys Ordered clustering key column names with their sort order.

Enterprise scenario

A connected-vehicle platform ingests tens of thousands of telemetry events per second from a global fleet into a single telemetry.device_readings table, partitioned by device_id and clustered by reading_time DESC so the dashboard’s “latest reading per vehicle” query is a single-partition lookup. On-demand capacity absorbs rush-hour ingestion spikes without any capacity planning, a 90-day default TTL keeps raw-event storage (and cost) bounded while older data is rolled up into a separate analytics keyspace, and PITR plus a customer-managed CMK satisfy the manufacturer’s automotive-grade restore-SLA and encryption-key-ownership requirements — all without operating a single Cassandra node, repair, or compaction job.

Best practices

TerraformAWSKeyspaces (Cassandra)ModuleIaC
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