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
- You are migrating a self-managed Apache Cassandra or DataStax cluster to a serverless backend and want to keep your CQL data model and drivers while standardizing table provisioning across services.
- You need a wide-column, high-write-throughput store (time-series, IoT telemetry, event logs, user activity feeds) with single-digit-millisecond reads at any scale.
- You want a clean separation between a shared keyspace (namespace, KMS key, tags) and many tables that each carry their own schema, capacity mode, and TTL.
- You run spiky or unpredictable traffic and want
PAY_PER_REQUESTso you never capacity-plan — or steady, predictable traffic wherePROVISIONEDread/write capacity units are cheaper, toggled per environment with one variable. - You require compliance controls: encryption with a customer-managed CMK, PITR for restore SLAs, and default TTL to bound storage growth and retention.
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 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/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
- Design the partition and clustering keys for your read path first. Keyspaces (like Cassandra) is fast only when queries hit a single partition; choose
partition_keysthat spread data evenly to avoid hot partitions, and orderclustering_keys(e.g.reading_time DESC) to match how you page results — getting this wrong forces expensive cross-partition scans no amount of capacity will fix. - Keep PITR on for prod and treat it as your restore SLA. This module defaults
point_in_time_recovery_enabledtotrue, giving per-second restore over a 35-day window; PITR adds storage cost but is the difference between a 2-minute recovery and irreversible data loss for a system of record. - Encrypt with a customer-managed CMK and scope IAM to the table ARN. Pass
kms_key_identifierso key rotation, grants, and audit stay under your control, and grant applications onlycassandra:Select/cassandra:Modifyon the specifictable_arn(orkeyspace_arnfor namespace-wide access) rather thancassandra:*on*. - Right-size the capacity mode per table. Use
PAY_PER_REQUESTfor spiky or unpredictable ingestion (no planning, no idle cost) and switch toPROVISIONEDRCUs/WCUs only for steady, high-volume traffic where reserved capacity is materially cheaper — you can changecapacity_modein place, so start on-demand and graduate when usage stabilizes. - Use default TTL to bound storage growth, not for exact timing. Set
default_time_to_livefor time-series and event tables so old rows expire automatically (you still pay write throughput to apply TTL, but deletes are free and asynchronous); don’t rely on TTL for correctness-critical deadlines since expiry is eventual. - Adopt consistent naming and leave client-side timestamps enabled. Suffix or separate keyspaces by environment (
telemetry,telemetry_staging) for clear blast-radius isolation, tag both keyspace and table withEnvironment/Team/CostCenterfor Cost Explorer attribution, and keepclient_side_timestamps_enabled = trueso concurrent writes resolve deterministically by last-writer-wins — a setting that cannot be turned off once on.