IaC AWS

Terraform Module: AWS Secrets Manager — KMS-encrypted secrets with automatic rotation and cross-account access

Quick take — A production-ready Terraform module for AWS Secrets Manager: customer-managed KMS encryption, Lambda-based rotation, recovery windows, replication, and least-privilege resource policies for hashicorp/aws ~> 5.0. 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 "secrets_manager" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-secrets-manager?ref=v1.0.0"

  # (no required inputs — all have sensible defaults)
}

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

What this module is

AWS Secrets Manager is a managed service for storing, rotating, and distributing sensitive values — database credentials, API keys, OAuth tokens, TLS private keys — without baking them into code, environment files, or AMIs. Unlike SSM Parameter Store SecureString values, Secrets Manager adds native secret rotation via Lambda, automatic multi-region replication, fine-grained resource-based policies, and built-in versioning with staging labels (AWSCURRENT, AWSPREVIOUS, AWSPENDING). It bills per secret per month plus per 10,000 API calls, so it is best reserved for credentials that genuinely benefit from rotation and controlled distribution.

The reason to wrap aws_secretsmanager_secret in a reusable module is that a correctly configured secret is more than one resource. In production you almost always want a customer-managed KMS key (so you can audit and revoke key access independently of the secret), an explicit recovery window, an initial version seeded through aws_secretsmanager_secret_version, a least-privilege resource policy that restricts who can GetSecretValue, and frequently a rotation schedule or cross-region replica. This module captures that full opinionated bundle behind a handful of variables, so every team provisions secrets the same hardened way instead of hand-rolling a bare secret with the AWS-managed aws/secretsmanager key and no policy.

When to use it

Module structure

terraform-module-aws-secrets-manager/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # Normalize tags and always stamp the module + secret name.
  tags = merge(
    var.tags,
    {
      "ManagedBy"  = "terraform"
      "Module"     = "terraform-module-aws-secrets-manager"
      "SecretName" = var.name
    }
  )

  # Only emit an initial version when the caller provides a value.
  create_initial_version = var.secret_string != null || var.secret_key_value != null

  # Prefer the structured key/value map; fall back to the raw string.
  initial_secret_payload = var.secret_key_value != null ? jsonencode(var.secret_key_value) : var.secret_string

  attach_rotation = var.rotation_lambda_arn != null
}

resource "aws_secretsmanager_secret" "this" {
  name                    = var.name_prefix != null ? null : var.name
  name_prefix             = var.name_prefix
  description             = var.description
  kms_key_id              = var.kms_key_id
  recovery_window_in_days = var.force_overwrite_replica_secret ? var.recovery_window_in_days : var.recovery_window_in_days
  policy                  = var.policy

  dynamic "replica" {
    for_each = var.replica_regions
    content {
      region     = replica.value.region
      kms_key_id = lookup(replica.value, "kms_key_id", null)
    }
  }

  tags = local.tags
}

# Seed the first version only if a payload was supplied. After creation,
# rotation or out-of-band writes own the value, so ignore drift on it.
resource "aws_secretsmanager_secret_version" "initial" {
  count = local.create_initial_version ? 1 : 0

  secret_id     = aws_secretsmanager_secret.this.id
  secret_string = local.initial_secret_payload

  lifecycle {
    ignore_changes = [secret_string]
  }
}

# Attach an automatic rotation schedule when a rotation Lambda is provided.
resource "aws_secretsmanager_secret_rotation" "this" {
  count = local.attach_rotation ? 1 : 0

  secret_id           = aws_secretsmanager_secret.this.id
  rotation_lambda_arn = var.rotation_lambda_arn

  rotation_rules {
    automatically_after_days = var.rotation_automatically_after_days
    duration                 = var.rotation_duration
    schedule_expression      = var.rotation_schedule_expression
  }
}

variables.tf

variable "name" {
  description = "Full name of the secret. Ignored when name_prefix is set. Use a hierarchical path like 'prod/payments/rds-master'."
  type        = string
  default     = null

  validation {
    condition     = var.name == null || can(regex("^[A-Za-z0-9/_+=.@-]{1,512}$", var.name))
    error_message = "name may only contain A-Z a-z 0-9 and the characters / _ + = . @ - and be 1-512 chars."
  }
}

variable "name_prefix" {
  description = "Creates a unique name beginning with this prefix. Mutually exclusive with name; useful for create_before_destroy workflows."
  type        = string
  default     = null
}

variable "description" {
  description = "Human-readable description of what the secret holds and who consumes it."
  type        = string
  default     = "Managed by Terraform"
}

variable "kms_key_id" {
  description = "ARN or key ID of the customer-managed KMS key used to encrypt the secret. Leave null to use the AWS-managed aws/secretsmanager key (not recommended for regulated workloads)."
  type        = string
  default     = null
}

variable "recovery_window_in_days" {
  description = "Days AWS retains the secret after deletion before permanent removal. Set to 0 to force immediate deletion (no recovery)."
  type        = number
  default     = 30

  validation {
    condition     = var.recovery_window_in_days == 0 || (var.recovery_window_in_days >= 7 && var.recovery_window_in_days <= 30)
    error_message = "recovery_window_in_days must be 0 (immediate) or between 7 and 30."
  }
}

variable "force_overwrite_replica_secret" {
  description = "Whether to overwrite a secret with the same name in a replica region during creation."
  type        = bool
  default     = false
}

variable "policy" {
  description = "JSON resource-based policy controlling who may access the secret (e.g. restrict GetSecretValue to a specific role). Pass null for no resource policy."
  type        = string
  default     = null
}

variable "secret_string" {
  description = "Raw initial secret value as a plain string. Mutually exclusive with secret_key_value. Drift is ignored after first apply so rotation can take over."
  type        = string
  default     = null
  sensitive   = true
}

variable "secret_key_value" {
  description = "Initial secret value as a key/value map, JSON-encoded into the secret (e.g. { username = \"app\", password = \"...\" }). Mutually exclusive with secret_string."
  type        = map(string)
  default     = null
  sensitive   = true
}

variable "replica_regions" {
  description = "List of regions to replicate the secret to, each optionally with its own kms_key_id for the replica."
  type = list(object({
    region     = string
    kms_key_id = optional(string)
  }))
  default = []
}

variable "rotation_lambda_arn" {
  description = "ARN of the Lambda function that performs rotation. When set, an aws_secretsmanager_secret_rotation is created."
  type        = string
  default     = null
}

variable "rotation_automatically_after_days" {
  description = "Number of days between automatic rotations. Used only when rotation_lambda_arn is set and rotation_schedule_expression is null."
  type        = number
  default     = 30

  validation {
    condition     = var.rotation_automatically_after_days >= 1 && var.rotation_automatically_after_days <= 1000
    error_message = "rotation_automatically_after_days must be between 1 and 1000."
  }
}

variable "rotation_duration" {
  description = "Length of the rotation window in hours, e.g. '3h'. Set to null to let AWS choose."
  type        = string
  default     = null
}

variable "rotation_schedule_expression" {
  description = "cron() or rate() expression for rotation timing. When set, it takes precedence over automatically_after_days."
  type        = string
  default     = null
}

variable "tags" {
  description = "Additional tags merged onto the secret."
  type        = map(string)
  default     = {}
}

outputs.tf

output "secret_arn" {
  description = "ARN of the secret. Use this in IAM policies and to reference the secret from other services."
  value       = aws_secretsmanager_secret.this.arn
}

output "secret_id" {
  description = "ID of the secret (equal to its ARN), suitable for aws_secretsmanager_secret_version lookups."
  value       = aws_secretsmanager_secret.this.id
}

output "secret_name" {
  description = "Final name of the secret, including any random suffix when name_prefix is used."
  value       = aws_secretsmanager_secret.this.name
}

output "kms_key_id" {
  description = "KMS key ID/ARN encrypting the secret (null when the AWS-managed key is used)."
  value       = aws_secretsmanager_secret.this.kms_key_id
}

output "version_id" {
  description = "Version ID of the initial secret value, or null if no initial value was seeded."
  value       = try(aws_secretsmanager_secret_version.initial[0].version_id, null)
}

output "rotation_enabled" {
  description = "Whether automatic rotation is configured for this secret."
  value       = local.attach_rotation
}

How to use it

data "aws_iam_role" "payments_task" {
  name = "ecs-payments-task-role"
}

# Least-privilege policy: only the payments ECS task role can read this secret.
data "aws_iam_policy_document" "rds_master_policy" {
  statement {
    sid     = "AllowPaymentsTaskRead"
    effect  = "Allow"
    actions = ["secretsmanager:GetSecretValue"]

    principals {
      type        = "AWS"
      identifiers = [data.aws_iam_role.payments_task.arn]
    }

    resources = ["*"]
  }
}

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

  name        = "prod/payments/rds-master"
  description = "Aurora PostgreSQL master credentials for the payments service"
  kms_key_id  = aws_kms_key.secrets.arn

  secret_key_value = {
    username = "payments_admin"
    engine   = "postgres"
    host     = aws_rds_cluster.payments.endpoint
    port     = "5432"
    dbname   = "payments"
    password = random_password.rds_master.result
  }

  recovery_window_in_days = 30
  policy                  = data.aws_iam_policy_document.rds_master_policy.json

  # Rotate every 14 days via the RDS rotation Lambda, and keep a DR replica.
  rotation_lambda_arn               = aws_lambda_function.rds_rotator.arn
  rotation_automatically_after_days = 14

  replica_regions = [
    { region = "ap-south-1", kms_key_id = aws_kms_key.secrets_mumbai.arn },
  ]

  tags = {
    Environment = "prod"
    CostCenter  = "payments"
    DataClass   = "confidential"
  }
}

# Downstream: inject the secret ARN into an ECS task definition so the
# container resolves it at launch via the 'secrets' block.
resource "aws_ecs_task_definition" "payments" {
  family                   = "payments"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "512"
  memory                   = "1024"
  execution_role_arn       = aws_iam_role.payments_execution.arn
  task_role_arn            = data.aws_iam_role.payments_task.arn

  container_definitions = jsonencode([
    {
      name      = "payments"
      image     = "${aws_ecr_repository.payments.repository_url}:latest"
      essential = true
      secrets = [
        {
          name      = "DB_CREDENTIALS"
          valueFrom = module.secrets_manager.secret_arn
        }
      ]
    }
  ])
}

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

include "root" {
  path = find_in_parent_folders()
}

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

inputs = {
  # (no required inputs)
}

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

cd live/prod/secrets_manager && 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
name string null No* Full secret name; use a hierarchical path. Ignored when name_prefix is set.
name_prefix string null No* Prefix for a generated unique name; mutually exclusive with name.
description string "Managed by Terraform" No What the secret holds and who consumes it.
kms_key_id string null No Customer-managed KMS key ARN/ID; null uses the AWS-managed key.
recovery_window_in_days number 30 No Retention days after deletion; 0 deletes immediately, else 7–30.
force_overwrite_replica_secret bool false No Overwrite a same-named secret in a replica region on create.
policy string null No JSON resource-based policy restricting access.
secret_string string null No Raw initial value; mutually exclusive with secret_key_value.
secret_key_value map(string) null No Initial value as a key/value map, JSON-encoded into the secret.
replica_regions list(object({ region, kms_key_id })) [] No Regions to replicate to, each with an optional replica KMS key.
rotation_lambda_arn string null No Rotation Lambda ARN; enables aws_secretsmanager_secret_rotation.
rotation_automatically_after_days number 30 No Days between rotations (1–1000) when no schedule expression is set.
rotation_duration string null No Rotation window length in hours, e.g. "3h".
rotation_schedule_expression string null No cron()/rate() expression; overrides automatically_after_days.
tags map(string) {} No Additional tags merged onto the secret.

*Provide exactly one of name or name_prefix.

Outputs

Name Description
secret_arn ARN of the secret; use in IAM policies and service references.
secret_id Secret ID (equal to its ARN) for version lookups.
secret_name Final secret name, including any generated suffix.
kms_key_id KMS key ID/ARN encrypting the secret (null for the AWS-managed key).
version_id Version ID of the seeded initial value, or null if none.
rotation_enabled Whether automatic rotation is configured.

Enterprise scenario

A fintech running a multi-account AWS Organization standardizes every database and third-party credential on this module from its platform Terraform. The payments team’s Aurora master secret is encrypted with a dedicated, auditable KMS key, rotated every 14 days by a shared RDS rotation Lambda, and replicated to ap-south-1 so the Mumbai DR region can read credentials during a failover. A resource policy on each secret limits GetSecretValue to the owning service’s ECS task role, so even a compromised account-admin session in a different team cannot exfiltrate the payments database password — satisfying the auditors’ separation-of-duties requirement for PCI-DSS scope.

Best practices

TerraformAWSSecrets ManagerModuleIaC
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