Quick take — A reusable Terraform module for AWS SSM Parameter Store: create String, StringList, and SecureString parameters with tier control, KMS encryption, allowed-pattern validation, and clean outputs for downstream wiring. 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 "ssm_parameter" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ssm-parameter?ref=v1.0.0"
name = "..." # Fully-qualified hierarchical parameter name, e.g. `/mya…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
AWS Systems Manager (SSM) Parameter Store is a managed key-value store for configuration data and secrets, addressed by hierarchical names like /myapp/prod/db/host. It gives you versioned values, optional KMS encryption for SecureString parameters, free Standard-tier storage for the first 10,000 parameters, and a Advanced tier for values up to 8 KB and for parameter policies (expiration, notification). Unlike Secrets Manager, Parameter Store has no per-secret monthly charge on the Standard tier, which makes it the default home for the vast majority of an organization’s non-rotated application config.
The raw aws_ssm_parameter resource is deceptively simple but has sharp edges that get repeated across every team: choosing tier correctly (and the surprise upgrade to Advanced when a value exceeds 4 KB), wiring a kms_key_id only when type = "SecureString", avoiding perpetual drift when the value is rotated outside Terraform, and enforcing a consistent naming hierarchy. This module wraps all of that into a single, validated interface so every parameter your platform creates is consistent, encrypted by the right key, and tagged uniformly.
When to use it
- You store application configuration (feature flags, endpoints, connection strings, non-rotated credentials) and want it in IaC rather than hand-edited in the console.
- You want
SecureStringvalues encrypted with a specific customer-managed KMS key per environment, not the sharedaws/ssmAWS-managed key. - You need to enforce naming conventions and an allowed-value pattern (for example, only
true/falsefor a feature flag) at plan time. - You want a value to be bootstrapped by Terraform once and then mutated by a pipeline or rotation Lambda without Terraform fighting the change on every apply.
- You are NOT looking for automatic credential rotation, cross-account secret replication, or generated random passwords with staged versions — that is Secrets Manager’s job, not this module’s.
Module structure
terraform-module-aws-ssm-parameter/
├── 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 {
# SecureString is the only type that accepts a KMS key. For String/StringList
# we force the key to null so a passed-in key never produces an invalid request.
effective_kms_key_id = var.type == "SecureString" ? var.kms_key_id : null
# A value over 4096 bytes cannot live in the Standard tier. If the caller left
# tier on the default "Standard" but the payload is too big, auto-promote to
# Advanced instead of failing the apply. An explicit tier is always respected.
computed_tier = (
var.tier == "Standard" && length(var.value) > 4096
? "Advanced"
: var.tier
)
}
resource "aws_ssm_parameter" "this" {
name = var.name
description = var.description
type = var.type
tier = local.computed_tier
# For SecureString values driven by an external process (rotation Lambda,
# pipeline), set manage_value = false to stop Terraform from re-asserting the
# bootstrap value on every plan. The value is written once on create only.
value = var.manage_value ? var.value : null
key_id = local.effective_kms_key_id
allowed_pattern = var.allowed_pattern
data_type = var.data_type
# Standard-tier parameters do not support policies; only attach when provided.
policies = var.policies
tags = var.tags
lifecycle {
# When the value is managed out-of-band, ignore drift on value/version so an
# external write does not show up as a Terraform change.
ignore_changes = var.manage_value ? [] : [value, version]
}
}
# variables.tf
variable "name" {
description = "Fully-qualified hierarchical parameter name, e.g. /myapp/prod/db/host."
type = string
validation {
condition = can(regex("^/?([a-zA-Z0-9_.\\-]+/)*[a-zA-Z0-9_.\\-]+$", var.name))
error_message = "name must be a valid SSM path (segments of letters, digits, '_', '.', '-' separated by '/')."
}
validation {
condition = length(var.name) <= 2048
error_message = "name must be 2048 characters or fewer."
}
}
variable "description" {
description = "Human-readable description of what this parameter holds."
type = string
default = null
}
variable "type" {
description = "Parameter type: String, StringList, or SecureString."
type = string
default = "String"
validation {
condition = contains(["String", "StringList", "SecureString"], var.type)
error_message = "type must be one of: String, StringList, SecureString."
}
}
variable "value" {
description = "Parameter value. For StringList use a comma-separated string. For SecureString this is the plaintext that AWS encrypts with key_id."
type = string
default = null
sensitive = true
validation {
condition = var.value == null || length(var.value) <= 8192
error_message = "value must be 8192 bytes or fewer (Advanced tier max)."
}
}
variable "manage_value" {
description = "If true, Terraform owns the value and re-asserts it on every apply. Set false to bootstrap once and let an external process (rotation, pipeline) own the value thereafter."
type = bool
default = true
}
variable "tier" {
description = "Parameter tier: Standard, Advanced, or Intelligent-Tiering. Standard auto-promotes to Advanced if value exceeds 4 KB."
type = string
default = "Standard"
validation {
condition = contains(["Standard", "Advanced", "Intelligent-Tiering"], var.tier)
error_message = "tier must be one of: Standard, Advanced, Intelligent-Tiering."
}
}
variable "kms_key_id" {
description = "KMS key id, ARN, or alias used to encrypt a SecureString. Ignored for String/StringList. If null, AWS uses the account's aws/ssm key."
type = string
default = null
}
variable "allowed_pattern" {
description = "Optional regex the value must match, validated by SSM on write (e.g. ^(true|false)$ for a flag)."
type = string
default = null
}
variable "data_type" {
description = "Data type for the value. Use 'text' for normal values or 'aws:ec2:image' / 'aws:ssm:integration' for validated references."
type = string
default = "text"
validation {
condition = contains(["text", "aws:ec2:image", "aws:ssm:integration"], var.data_type)
error_message = "data_type must be one of: text, aws:ec2:image, aws:ssm:integration."
}
}
variable "policies" {
description = "Optional JSON string of Advanced-tier parameter policies (Expiration, ExpirationNotification, NoChangeNotification). Requires Advanced tier."
type = string
default = null
}
variable "tags" {
description = "Tags to apply to the parameter."
type = map(string)
default = {}
}
# outputs.tf
output "id" {
description = "The parameter name (the ID of the aws_ssm_parameter resource)."
value = aws_ssm_parameter.this.id
}
output "name" {
description = "The fully-qualified name of the parameter."
value = aws_ssm_parameter.this.name
}
output "arn" {
description = "The ARN of the parameter, for use in IAM policies (ssm:GetParameter)."
value = aws_ssm_parameter.this.arn
}
output "version" {
description = "The version number, incremented on each value change."
value = aws_ssm_parameter.this.version
}
output "type" {
description = "The resolved parameter type."
value = aws_ssm_parameter.this.type
}
output "tier" {
description = "The resolved tier (may be auto-promoted to Advanced for large values)."
value = aws_ssm_parameter.this.tier
}
output "value" {
description = "The current parameter value. Marked sensitive."
value = aws_ssm_parameter.this.value
sensitive = true
}
How to use it
# A customer-managed key for this environment's secrets.
data "aws_kms_key" "ssm" {
key_id = "alias/prod-ssm"
}
# A SecureString that Terraform bootstraps once, then a rotation Lambda owns.
module "ssm_parameter_store" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ssm-parameter?ref=v1.0.0"
name = "/payments-api/prod/stripe/secret-key"
description = "Stripe secret key for the payments service (rotated out-of-band)."
type = "SecureString"
value = var.stripe_bootstrap_secret # only written on first create
manage_value = false # external rotation owns it after
kms_key_id = data.aws_kms_key.ssm.arn
tags = {
Environment = "prod"
Service = "payments-api"
ManagedBy = "terraform"
}
}
# A non-secret feature flag with a strict allowed pattern.
module "feature_flag_new_checkout" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ssm-parameter?ref=v1.0.0"
name = "/payments-api/prod/flags/new-checkout"
description = "Enables the redesigned checkout flow."
type = "String"
value = "false"
allowed_pattern = "^(true|false)$"
tags = {
Environment = "prod"
Service = "payments-api"
}
}
# Downstream: grant an ECS task role read access using the module's ARN output.
data "aws_iam_policy_document" "read_stripe_key" {
statement {
sid = "ReadStripeSecret"
actions = ["ssm:GetParameter", "ssm:GetParameters"]
resources = [module.ssm_parameter_store.arn]
}
statement {
sid = "DecryptSsmSecret"
actions = ["kms:Decrypt"]
resources = [data.aws_kms_key.ssm.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/ssm_parameter/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-ssm-parameter?ref=v1.0.0"
}
inputs = {
name = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/ssm_parameter && 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 |
— | Yes | Fully-qualified hierarchical parameter name, e.g. /myapp/prod/db/host. |
description |
string |
null |
No | Human-readable description of what this parameter holds. |
type |
string |
"String" |
No | Parameter type: String, StringList, or SecureString. |
value |
string |
null |
No | Parameter value (comma-separated for StringList; plaintext for SecureString). Marked sensitive. |
manage_value |
bool |
true |
No | If true, Terraform owns the value; if false, bootstrap once and ignore later drift. |
tier |
string |
"Standard" |
No | Standard, Advanced, or Intelligent-Tiering. Auto-promotes to Advanced past 4 KB. |
kms_key_id |
string |
null |
No | KMS key id/ARN/alias for SecureString. Ignored for other types. |
allowed_pattern |
string |
null |
No | Regex the value must match, enforced by SSM on write. |
data_type |
string |
"text" |
No | text, aws:ec2:image, or aws:ssm:integration. |
policies |
string |
null |
No | JSON string of Advanced-tier parameter policies. Requires Advanced tier. |
tags |
map(string) |
{} |
No | Tags to apply to the parameter. |
Outputs
| Name | Description |
|---|---|
id |
The parameter name (the resource ID). |
name |
The fully-qualified name of the parameter. |
arn |
The ARN of the parameter, for IAM ssm:GetParameter policies. |
version |
The version number, incremented on each value change. |
type |
The resolved parameter type. |
tier |
The resolved tier (may be auto-promoted to Advanced). |
value |
The current parameter value. Marked sensitive. |
Enterprise scenario
A fintech platform runs 40 microservices across dev, staging, and prod accounts, each consuming config under a /<service>/<env>/... hierarchy. The platform team standardises on this module so that every SecureString is encrypted with the per-environment customer-managed KMS key (satisfying their auditors’ requirement that no secret uses the shared aws/ssm key), every feature flag carries an allowed_pattern, and third-party API keys are bootstrapped by Terraform but handed off to a rotation Lambda via manage_value = false. Because the module emits each parameter’s ARN, the service modules build least-privilege IAM policies that grant ssm:GetParameter on exactly the paths a task needs and nothing else.
Best practices
- Use
SecureStringwith a customer-managed KMS key (not the defaultaws/ssmkey) for anything sensitive, and scopekms:Decryptto the consuming role — this gives you per-secret access control and CloudTrail-visible decrypt events. - Enforce a strict naming hierarchy (
/<service>/<env>/<group>/<key>) so you can grant IAMssm:GetParameteron path prefixes likearn:aws:ssm:*:*:parameter/payments-api/prod/*and useGetParametersByPathto bulk-load config at startup. - Stay on the
Standardtier whenever values fit in 4 KB — it is free for the first 10,000 parameters; only opt intoAdvancedwhen you need >4 KB values or parameter policies (expiration/notification), since Advanced is billed per parameter per month. - For values rotated outside Terraform, set
manage_value = falseso the bootstrap value is written once and later changes are ignored, eliminating perpetual plan diffs and accidental secret reversions. - Apply an
allowed_patternto constrained values (booleans, enums, numeric ranges) so a bad value is rejected by SSM at write time rather than discovered as a runtime failure downstream. - Never log or
outputaSecureStringvalue unmasked; keep thevaluevariable and outputsensitive, and prefer referencing parameters by ARN/name in other resources rather than reading the plaintext into state where avoidable.