IaC AWS

Terraform Module: AWS SSM Parameter Store — typed, tier-aware parameters with KMS and drift-safe values

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

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

TerraformAWSSSM Parameter StoreModuleIaC
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