IaC AWS

Terraform Module: AWS QLDB — a deletion-protected, KMS-encrypted immutable ledger in one call

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

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 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/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

TerraformAWSQLDBModuleIaC
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