Quick take — Provision Amazon QLDB ledgers with Terraform: STANDARD permissions mode, customer-managed KMS encryption, deletion protection, and an optional CloudWatch journal-block alarm — all var-driven. 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 "qldb" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-qldb?ref=v1.0.0"
ledger_name = "..." # Ledger name, unique per account/region, immutable after…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Amazon QLDB (Quantum Ledger Database) is a fully managed ledger database that keeps a complete, immutable, cryptographically verifiable history of every change made to your data. Unlike a regular table you can UPDATE and forget, QLDB maintains an append-only journal: every committed transaction is hashed and chained to the previous one, so you can prove — to an auditor, a regulator, or a court — that a record was never silently altered. You get the journal, the current-state views, and a SHA-256 hash chain you can independently verify, without standing up and securing your own blockchain.
The aws_qldb_ledger resource itself is deceptively small — a name, a permissions mode, an encryption setting, and a deletion-protection flag. The danger is in the defaults. Spin one up casually and you can land on the deprecated ALLOW_ALL permissions mode, AWS-owned encryption keys instead of a key you control, and deletion protection turned off on a database whose entire reason for existing is that nothing should ever disappear. This module bakes the safe, audit-grade defaults in: STANDARD (table/PartiQL-scoped IAM) permissions, a customer-managed KMS key, deletion protection on, and an optional CloudWatch alarm on the JournalStorageSize/write path so you find out about runaway journal growth before the bill does. Wrap it once, and every team that needs a tamper-evident system of record gets the same hardened ledger from a single module block.
When to use it
- Systems of record that must be provably immutable — financial sub-ledgers, crypto/asset custody, payroll history, insurance claims, supply-chain provenance — where “trust me, we didn’t change it” is not an acceptable answer.
- Regulatory and audit trails (SOX, PCI-DSS, HIPAA event logs, MiFID II) where you need to cryptographically verify that a specific revision existed at a point in time.
- Centralised, single-owner ledgers — QLDB is owned and written by one trusted authority (your application), as opposed to a multi-party decentralised blockchain like Amazon Managed Blockchain.
- When you want the immutability and verifiability of a ledger without running validator nodes, managing consensus, or hand-rolling hash-chain verification.
Reach for something else when you need a multi-writer, no-central-trust network (use Amazon Managed Blockchain), or when you just need point-in-time recovery of mutable data (use DynamoDB/RDS with PITR). QLDB is for append-only history with proof, not for general-purpose OLTP.
Heads up on lifecycle: AWS has announced end-of-support for Amazon QLDB (with migration guidance toward Amazon Aurora PostgreSQL). Treat new ledgers as legacy/maintenance workloads, plan an exit path, and keep the journal-export wiring in this module so you can stream the verifiable history out to S3 at any time.
Module structure
terraform-module-aws-qldb/
├── versions.tf # provider + Terraform version pins
├── main.tf # aws_qldb_ledger + optional journal-S3 export role + CloudWatch alarm
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id, arn, name, KMS key arn
versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
main.tf
locals {
# When kms_key is "AWS_OWNED_KMS_KEY" QLDB uses an AWS-owned key (no key ARN exposed).
# Otherwise we expect a customer-managed CMK ARN.
using_customer_kms = var.kms_key != "AWS_OWNED_KMS_KEY"
tags = merge(
{
Name = var.ledger_name
ManagedBy = "terraform"
Module = "terraform-module-aws-qldb"
},
var.tags
)
}
resource "aws_qldb_ledger" "this" {
name = var.ledger_name
permissions_mode = var.permissions_mode
deletion_protection = var.deletion_protection
# ARN of a customer-managed CMK, or the literal "AWS_OWNED_KMS_KEY".
kms_key = var.kms_key
tags = local.tags
}
# ---------------------------------------------------------------------------
# Optional: IAM role that QLDB assumes to stream journal blocks to an S3
# bucket (journal export / streaming). Created only when an export bucket
# ARN is supplied. This is the recommended way to retain verifiable history
# outside the ledger and to support a future migration off QLDB.
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "assume" {
count = var.journal_export_bucket_arn == null ? 0 : 1
statement {
effect = "Allow"
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["qldb.amazonaws.com"]
}
}
}
data "aws_iam_policy_document" "journal_export" {
count = var.journal_export_bucket_arn == null ? 0 : 1
statement {
sid = "AllowJournalBlockPut"
effect = "Allow"
actions = [
"s3:PutObject",
"s3:PutObjectAcl",
]
resources = ["${var.journal_export_bucket_arn}/*"]
}
dynamic "statement" {
for_each = local.using_customer_kms ? [1] : []
content {
sid = "AllowKmsForExport"
effect = "Allow"
actions = [
"kms:GenerateDataKey",
"kms:Decrypt",
]
resources = [var.kms_key]
}
}
}
resource "aws_iam_role" "journal_export" {
count = var.journal_export_bucket_arn == null ? 0 : 1
name = "${var.ledger_name}-qldb-journal-export"
assume_role_policy = data.aws_iam_policy_document.assume[0].json
tags = local.tags
}
resource "aws_iam_role_policy" "journal_export" {
count = var.journal_export_bucket_arn == null ? 0 : 1
name = "journal-export"
role = aws_iam_role.journal_export[0].id
policy = data.aws_iam_policy_document.journal_export[0].json
}
# ---------------------------------------------------------------------------
# Optional: CloudWatch alarm on journal storage growth. QLDB journal storage
# is append-only and never shrinks, so unbounded growth is a real cost/health
# signal. Created only when alarm_actions are supplied.
# ---------------------------------------------------------------------------
resource "aws_cloudwatch_metric_alarm" "journal_storage" {
count = length(var.alarm_actions) > 0 ? 1 : 0
alarm_name = "${var.ledger_name}-qldb-journal-storage"
alarm_description = "QLDB ${var.ledger_name} journal storage exceeded threshold (append-only, never shrinks)."
namespace = "AWS/QLDB"
metric_name = "JournalStorage"
statistic = "Maximum"
comparison_operator = "GreaterThanThreshold"
threshold = var.journal_storage_alarm_bytes
period = 3600
evaluation_periods = 1
treat_missing_data = "notBreaching"
dimensions = {
LedgerName = aws_qldb_ledger.this.name
}
alarm_actions = var.alarm_actions
ok_actions = var.alarm_actions
tags = local.tags
}
variables.tf
variable "ledger_name" {
description = "Name of the QLDB ledger. Unique per AWS account/region; immutable after creation."
type = string
validation {
condition = can(regex("^[A-Za-z0-9_-]{1,32}$", var.ledger_name))
error_message = "ledger_name must be 1-32 chars: letters, digits, underscore or hyphen only."
}
}
variable "permissions_mode" {
description = "IAM permissions mode for the ledger. STANDARD scopes access per-table/PartiQL action; ALLOW_ALL is deprecated and insecure."
type = string
default = "STANDARD"
validation {
condition = contains(["STANDARD", "ALLOW_ALL"], var.permissions_mode)
error_message = "permissions_mode must be either STANDARD or ALLOW_ALL (STANDARD strongly recommended)."
}
}
variable "deletion_protection" {
description = "Protects the ledger from accidental deletion. Must be disabled (set false) before terraform destroy can remove the ledger."
type = bool
default = true
}
variable "kms_key" {
description = "ARN of a customer-managed KMS CMK to encrypt the ledger, or the literal string AWS_OWNED_KMS_KEY to use an AWS-owned key."
type = string
default = "AWS_OWNED_KMS_KEY"
validation {
condition = (
var.kms_key == "AWS_OWNED_KMS_KEY" ||
can(regex("^arn:aws[a-zA-Z-]*:kms:[a-z0-9-]+:[0-9]{12}:key/.+$", var.kms_key))
)
error_message = "kms_key must be a valid KMS key ARN or the literal string AWS_OWNED_KMS_KEY."
}
}
variable "journal_export_bucket_arn" {
description = "Optional S3 bucket ARN. When set, an IAM role QLDB can assume to export/stream journal blocks to that bucket is created. Leave null to skip."
type = string
default = null
}
variable "alarm_actions" {
description = "Optional list of SNS topic ARNs to notify on the journal-storage CloudWatch alarm. Empty list disables the alarm."
type = list(string)
default = []
}
variable "journal_storage_alarm_bytes" {
description = "Threshold in bytes for the journal-storage alarm. Default ~10 GiB."
type = number
default = 10737418240
validation {
condition = var.journal_storage_alarm_bytes > 0
error_message = "journal_storage_alarm_bytes must be a positive number of bytes."
}
}
variable "tags" {
description = "Additional tags merged onto every resource created by the module."
type = map(string)
default = {}
}
outputs.tf
output "id" {
description = "The QLDB ledger name (Terraform resource id)."
value = aws_qldb_ledger.this.id
}
output "name" {
description = "Name of the QLDB ledger."
value = aws_qldb_ledger.this.name
}
output "arn" {
description = "ARN of the QLDB ledger, e.g. for IAM policy resource scoping."
value = aws_qldb_ledger.this.arn
}
output "kms_key" {
description = "The KMS key associated with the ledger (CMK ARN or AWS_OWNED_KMS_KEY)."
value = aws_qldb_ledger.this.kms_key
}
output "journal_export_role_arn" {
description = "ARN of the IAM role QLDB assumes to export journal blocks to S3, or null when no export bucket was supplied."
value = try(aws_iam_role.journal_export[0].arn, null)
}
How to use it
module "qldb" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-qldb?ref=v1.0.0"
ledger_name = "payments-audit-prod"
permissions_mode = "STANDARD"
deletion_protection = true
# Customer-managed CMK so we control rotation and revocation of the audit ledger.
kms_key = aws_kms_key.ledger.arn
# Stream verifiable journal blocks to S3 for long-term retention / future migration.
journal_export_bucket_arn = aws_s3_bucket.journal_archive.arn
# Page us if the append-only journal grows past 25 GiB.
alarm_actions = [aws_sns_topic.data_platform_alerts.arn]
journal_storage_alarm_bytes = 26843545600
tags = {
Environment = "prod"
CostCenter = "fin-platform"
Compliance = "sox"
}
}
# Downstream: scope an application's IAM policy to exactly this ledger using its ARN output.
data "aws_iam_policy_document" "app_qldb_access" {
statement {
sid = "PartiQLOnPaymentsLedger"
effect = "Allow"
actions = [
"qldb:SendCommand", # PartiQL data-plane (execute statements)
]
resources = [module.qldb.arn]
}
}
resource "aws_iam_role_policy" "app_qldb" {
name = "payments-app-qldb"
role = aws_iam_role.payments_app.id
policy = data.aws_iam_policy_document.app_qldb_access.json
}
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/qldb/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-qldb?ref=v1.0.0"
}
inputs = {
ledger_name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/qldb && 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 |
|---|---|---|---|---|
ledger_name |
string |
— | Yes | Ledger name, unique per account/region, immutable after creation (1-32 chars: letters, digits, _, -). |
permissions_mode |
string |
"STANDARD" |
No | IAM permissions mode. STANDARD scopes access per table/PartiQL action; ALLOW_ALL is deprecated. |
deletion_protection |
bool |
true |
No | Blocks accidental deletion; must be false before terraform destroy can remove the ledger. |
kms_key |
string |
"AWS_OWNED_KMS_KEY" |
No | Customer-managed KMS CMK ARN, or the literal AWS_OWNED_KMS_KEY for an AWS-owned key. |
journal_export_bucket_arn |
string |
null |
No | S3 bucket ARN; when set, creates an IAM role QLDB assumes to export/stream journal blocks. |
alarm_actions |
list(string) |
[] |
No | SNS topic ARNs for the journal-storage alarm. Empty list disables the alarm. |
journal_storage_alarm_bytes |
number |
10737418240 |
No | Journal-storage alarm threshold in bytes (default ~10 GiB). |
tags |
map(string) |
{} |
No | Additional tags merged onto every created resource. |
Outputs
| Name | Description |
|---|---|
id |
The QLDB ledger name (Terraform resource id). |
name |
Name of the QLDB ledger. |
arn |
ARN of the ledger, used to scope IAM policies to this specific ledger. |
kms_key |
The KMS key associated with the ledger (CMK ARN or AWS_OWNED_KMS_KEY). |
journal_export_role_arn |
ARN of the IAM role QLDB assumes to export journal blocks to S3, or null if no export bucket was supplied. |
Enterprise scenario
A payments company must keep an immutable, auditor-verifiable record of every ledger entry for its merchant settlement product, and SOX controls require proof that no historical balance was retroactively edited. The platform team consumes this module once per environment to stand up payments-audit-prod with STANDARD permissions, a dedicated customer-managed CMK (so Security can rotate and, in a breach, revoke access independently), deletion protection on, and journal blocks continuously exported to a write-once S3 bucket. When external auditors arrive, the team uses QLDB’s digest and GetRevision proof APIs against the same ledger ARN this module emits, demonstrating the SHA-256 hash chain end-to-end — and the S3 export gives them a clean, verifiable archive to migrate onto Aurora PostgreSQL ahead of QLDB’s end-of-support.
Best practices
- Always use
STANDARDpermissions mode.ALLOW_ALLgrants any IAM principal full access to every table and is deprecated — the module defaults toSTANDARDand scopes data-plane access viaqldb:SendCommandon the specific ledger ARN. - Encrypt with a customer-managed CMK, not the AWS-owned key. A CMK lets you control key rotation, audit
kms:Decryptin CloudTrail, and revoke access to the entire ledger’s data by disabling one key — critical for a system of record holding sensitive financial history. - Keep
deletion_protection = trueand treat it as a deliberate, two-step removal. Because the whole point of a ledger is that nothing vanishes, destroying one should require explicitly flipping the flag tofalsefirst; never let a strayterraform destroywipe an audit trail. - Export the journal to S3 for retention and exit. QLDB storage is append-only and you’re billed for it forever; streaming verifiable journal blocks to a lifecycle-managed (write-once / Object Lock) bucket gives you cheaper long-term retention and a migration path off QLDB.
- Watch journal growth and design for it. Set
alarm_actionsso unbounded journal storage triggers an SNS page; size documents lean, avoid high-churn “hot” documents, and remember historical revisions are never deleted, so write volume is a direct, permanent cost driver. - Name ledgers for their workload and environment (
payments-audit-prod), tag withCompliance/CostCenter, and keep one ledger per system of record per environment so IAM scoping, cost attribution, and audit boundaries stay clean.