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
- You need to store rotatable credentials (RDS/Aurora users, third-party API keys) where Secrets Manager’s
AWSPENDING/AWSCURRENTrotation flow and 30-day recovery window add real value over Parameter Store. - You require customer-managed KMS encryption for compliance (PCI-DSS, HIPAA, SOC 2) so you can prove key custody, enable key rotation, and revoke decrypt access in an incident.
- You operate multi-region or DR workloads and want the secret automatically replicated so a regional failover can still read credentials.
- You need cross-account or cross-service distribution governed by a resource policy — for example, letting a specific ECS task role or a partner account read exactly one secret and nothing else.
- Reach for plain SSM
SecureStringinstead when the value never rotates and is low-call-volume config (feature flags, non-secret endpoints) — it is materially cheaper.
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 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/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
- Always use a customer-managed KMS key (
kms_key_id) for regulated secrets so you can rotate the key, enable CloudTrail data events onkms:Decrypt, and revoke decrypt access independently of the secret itself — the defaultaws/secretsmanagerkey gives you none of that control. - Attach a least-privilege resource policy scoping
secretsmanager:GetSecretValueto specific principals; pair it with a non-0recovery_window_in_days(keep the default 30) so an accidental or malicious delete is recoverable. - Let rotation own the value after creation — this module sets
ignore_changes = [secret_string]on the seeded version so Terraform never clobbers a freshly rotated password, and you avoid storing the live secret in state on every plan. - Use hierarchical names like
prod/payments/rds-master; consistent prefixes make IAM policy wildcards (arn:aws:secretsmanager:*:*:secret:prod/payments/*) and per-team cost allocation tags straightforward. - Control cost by reserving Secrets Manager for genuinely rotatable, high-value credentials (it bills ~$0.40 per secret per month plus per-10K API calls); push static, low-call config to SSM Parameter Store
SecureStringand cacheGetSecretValueresults in your application rather than calling on every request. - Replicate only where you need DR via
replica_regions; each replica is billed as a separate secret and re-encrypted with the replica-region key, so unnecessary replicas quietly multiply both cost and your key-management surface.