AWS Builder Center
Stop Overpaying for Secrets You Never Rotate: Migrate to SSM Parameter Store with Terraform 🔐

Stop Overpaying for Secrets You Never Rotate: Migrate to SSM Parameter Store with Terraform 🔐

SSM Parameter Store SecureString is free for most use cases. Here's how to migrate safely with Terraform.

Series: Save on AWS with Terraform (21 articles)

  1. …
  2. 18
    Stop Overpaying for Secrets You Never Rotate: Migrate to SSM Parameter Store with Terraform 🔐 This article

AWS Secrets Manager charges $0.40/secret/month plus API call fees. SSM Parameter Store SecureString is free for most use cases. Here's how to migrate safely with Terraform.

Quick math: How many secrets do you have in AWS Secrets Manager?
1
2
3
4
20 secrets → $8/month → $96/year
50 secrets → $20/month → $240/year
100 secrets → $40/month → $480/year
200 secrets → $80/month → $960/year
Plus $0.05 per 10,000 API calls on top.
Now here's the kicker: SSM Parameter Store SecureString does the same thing for $0.00. 💰
Same KMS encryption. Same IAM access control. Same SDK integration. Zero cost (standard tier).
Let's migrate.

🤔 When to Stay vs When to Switch

Not every secret should move. Here's the honest breakdown:
Stay with Secrets Manager when: ✋
  • ❌ You use automatic rotation (RDS, Redshift, DocumentDB credentials)
  • ❌ You need cross-account sharing via resource policies
  • ❌ You use replica secrets across regions
  • ❌ You store secrets > 8KB (Secrets Manager supports 64KB)
Switch to SSM Parameter Store when: ✅
  • ✅ API keys, tokens, webhook URLs
  • ✅ Database connection strings (no auto-rotation needed)
  • ✅ Third-party service credentials
  • ✅ Environment variables and config values
  • ✅ Encryption keys and certificates (< 8KB)
  • ✅ Feature flags and app configuration
Reality check: In most accounts, 70-80% of secrets don't use rotation. Those are all candidates. 🎯

💸 Cost Comparison

Secrets ManagerSSM Parameter Store (Standard)
Storage$0.40/secret/monthFree
API calls$0.05/10K callsFree (up to 10K/sec)
EncryptionKMS (same)KMS (same)
Max size64KB8KB (Advanced: 8KB)
RotationBuilt-in ✅Manual/custom
Versioning✅✅
IAM policies✅✅
100 secrets/year$480+$0
Same security. Same encryption. $480/year difference on 100 secrets.

🏗️ Terraform Implementation

Step 1: Audit Your Current Secrets

Deploy this Lambda to identify migration candidates:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
# audit/secrets-audit.tf

resource "aws_lambda_function" "secrets_auditor" {
filename = data.archive_file.auditor.output_path
function_name = "secrets-migration-auditor"
role = aws_iam_role.auditor.arn
handler = "index.handler"
runtime = "python3.12"
timeout = 120
source_code_hash = data.archive_file.auditor.output_base64sha256

environment {
variables = {
SNS_TOPIC_ARN = aws_sns_topic.audit_results.arn
}
}
}

data "archive_file" "auditor" {
type = "zip"
output_path = "${path.module}/auditor.zip"

source {
content = <<-PYTHON
import boto3
import json
import os

sm = boto3.client('secretsmanager')
sns = boto3.client('sns')

def handler(event, context):
paginator = sm.get_paginator('list_secrets')

can_migrate = []
must_stay = []
total_cost = 0

for page in paginator.paginate():
for secret in page['SecretList']:
name = secret['Name']
total_cost += 0.40

has_rotation = secret.get('RotationEnabled', False)
has_replicas = len(secret.get('ReplicationStatus', [])) > 0

# Check secret size
try:
value = sm.get_secret_value(SecretId=name)
size = len(value.get('SecretString', '') or
value.get('SecretBinary', b''))
oversized = size > 8192 # 8KB SSM limit
except Exception:
oversized = False
size = 0

info = {
'name': name,
'rotation': has_rotation,
'replicas': has_replicas,
'size_bytes': size,
'oversized': oversized
}

if has_rotation or has_replicas or oversized:
info['reason'] = []
if has_rotation: info['reason'].append('rotation enabled')
if has_replicas: info['reason'].append('has replicas')
if oversized: info['reason'].append(f'oversized ({size} bytes)')
must_stay.append(info)
else:
can_migrate.append(info)

migrate_savings = len(can_migrate) * 0.40

report = (
f"Secrets Manager Migration Audit\n"
f"{'='*40}\n\n"
f"Total secrets: {len(can_migrate) + len(must_stay)}\n"
f"Current monthly cost: ${total_cost:.2f}\n\n"
f"CAN MIGRATE to SSM ({len(can_migrate)} secrets):\n"
)
for s in can_migrate:
report += f" ✅ {s['name']} ({s['size_bytes']} bytes)\n"

report += f"\nMUST STAY in Secrets Manager ({len(must_stay)} secrets):\n"
for s in must_stay:
reasons = ', '.join(s['reason'])
report += f" ❌ {s['name']} ({reasons})\n"

report += (
f"\nPotential monthly savings: ${migrate_savings:.2f}\n"
f"Potential annual savings: ${migrate_savings * 12:.2f}"
)

sns.publish(
TopicArn=os.environ['SNS_TOPIC_ARN'],
Subject=f'Secrets Audit: {len(can_migrate)} can migrate, save ${migrate_savings:.2f}/mo',
Message=report
)

return {
'can_migrate': len(can_migrate),
'must_stay': len(must_stay),
'monthly_savings': migrate_savings
}
PYTHON
filename = "index.py"
}
}

resource "aws_sns_topic" "audit_results" {
name = "secrets-migration-audit"
}

resource "aws_iam_role" "auditor" {
name = "secrets-auditor-role"

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
}

resource "aws_iam_role_policy" "auditor" {
name = "secrets-auditor-policy"
role = aws_iam_role.auditor.id

policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["secretsmanager:ListSecrets", "secretsmanager:GetSecretValue"]
Resource = "*"
},
{
Effect = "Allow"
Action = ["sns:Publish"]
Resource = aws_sns_topic.audit_results.arn
},
{
Effect = "Allow"
Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "arn:aws:logs:*:*:*"
}
]
})
}

Step 2: Migrate Secrets to SSM Parameter Store

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# migration/main.tf

# Before: Secrets Manager ($0.40/month each) 💸
resource "aws_secretsmanager_secret" "api_key" {
name = "myapp/api-key"
}
resource "aws_secretsmanager_secret_version" "api_key" {
secret_id = aws_secretsmanager_secret.api_key.id
secret_string = var.api_key
}

# After: SSM Parameter Store ($0.00/month) ✅
resource "aws_ssm_parameter" "api_key" {
name = "/myapp/api-key"
description = "Third-party API key"
type = "SecureString"
value = var.api_key
tier = "Standard" # Free!

tags = {
Environment = var.environment
MigratedFrom = "secrets-manager"
ManagedBy = "terraform"
}
}

Step 3: Bulk Migration Module

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# modules/secret-to-ssm/main.tf

variable "secrets" {
type = map(object({
value = string
description = string
environment = string
}))
}

variable "prefix" {
type = string
default = "" # e.g., "/myapp/prod"
}

resource "aws_ssm_parameter" "migrated" {
for_each = var.secrets

name = "${var.prefix}/${each.key}"
description = each.value.description
type = "SecureString"
value = each.value.value
tier = "Standard"

tags = {
Environment = each.value.environment
MigratedFrom = "secrets-manager"
ManagedBy = "terraform"
}
}

output "parameter_arns" {
value = { for k, v in aws_ssm_parameter.migrated : k => v.arn }
}
Usage:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
module "migrated_secrets" {
source = "./modules/secret-to-ssm"
prefix = "/myapp/prod"

secrets = {
"stripe-api-key" = {
value = var.stripe_key
description = "Stripe API key"
environment = "prod"
}
"sendgrid-token" = {
value = var.sendgrid_token
description = "SendGrid email token"
environment = "prod"
}
"slack-webhook" = {
value = var.slack_webhook
description = "Slack notification webhook URL"
environment = "prod"
}
"datadog-api-key" = {
value = var.datadog_key
description = "Datadog monitoring API key"
environment = "prod"
}
}
}
# 4 secrets × $0.40 = $1.60/mo saved. Scale that to 50+ secrets. 💰

Step 4: Update Your Application Code

The SDK change is minimal:
1
2
3
4
5
6
7
8
9
10
11
# Before: Secrets Manager
import boto3
sm = boto3.client('secretsmanager')
secret = sm.get_secret_value(SecretId='myapp/api-key')
api_key = secret['SecretString']

# After: SSM Parameter Store ✅
import boto3
ssm = boto3.client('ssm')
param = ssm.get_parameter(Name='/myapp/api-key', WithDecryption=True)
api_key = param['Parameter']['Value']
1
2
3
4
5
6
7
8
9
// Before: Secrets Manager
const sm = new AWS.SecretsManager();
const secret = await sm.getSecretValue({ SecretId: 'myapp/api-key' }).promise();
const apiKey = secret.SecretString;

// After: SSM Parameter Store ✅
const ssm = new AWS.SSM();
const param = await ssm.getParameter({ Name: '/myapp/api-key', WithDecryption: true }).promise();
const apiKey = param.Parameter.Value;
For Lambda functions, SSM is even easier with the built-in extension:
1
2
3
4
5
6
7
8
9
resource "aws_lambda_function" "my_api" {
# ...
environment {
variables = {
# Reference SSM parameter directly
API_KEY_PARAM = aws_ssm_parameter.api_key.name
}
}
}

⚡ Safe Migration Checklist

Don't just delete Secrets Manager secrets. Follow this order:
1
2
3
4
5
6
7
8
1. ✅ Deploy SSM parameters (Terraform apply)
2. ✅ Update application code to read from SSM
3. ✅ Deploy application update
4. ✅ Verify app works with SSM parameters
5. ✅ Monitor for 48 hours
6. ✅ Check CloudTrail — no more GetSecretValue calls
7. ✅ THEN remove Secrets Manager resources from Terraform
8. ✅ Terraform apply to delete old secrets
⚠️ Never delete the Secrets Manager secret before confirming the app reads from SSM. Run both in parallel during the transition.

💡 Pro Tips

  • Naming convention matters — Use hierarchical paths like /app/env/secret-name. SSM supports path-based GetParametersByPath to fetch all secrets for an app at once
  • Use Standard tier — Advanced tier costs $0.05/parameter/month. You only need it for policies, expiration, or >10K parameters. Standard is free for up to 10,000 parameters
  • KMS is the same — Both services use KMS for encryption. Your security posture doesn't change
  • IAM granularity — SSM supports path-based policies: arn:aws:ssm:*:*:parameter/myapp/prod/* restricts access to just prod secrets
  • Don't migrate rotation-dependent secrets — If RDS credentials auto-rotate via Secrets Manager, leave them. The rotation Lambda integration isn't worth rebuilding

📊 TL;DR

Secrets CountSecrets Manager/YearSSM Parameter Store/YearAnnual Savings
20$96$0$96
50$240$0$240
100$480$0$480
200$960$0$960
Bottom line: If your secrets don't rotate automatically, you're paying a $0.40/month tax per secret for nothing. SSM Parameter Store gives you the same encryption, same access control, same SDK experience — for free. 🆓

Go count your Secrets Manager secrets right now. Multiply by $0.40. That's what you're paying monthly for a feature you're probably not using. 😏
Found this helpful? Follow for more AWS cost optimization with Terraform! 💬

Series: Save on AWS with Terraform (21 articles)

  1. …
  2. 18
    Stop Overpaying for Secrets You Never Rotate: Migrate to SSM Parameter Store with Terraform 🔐 This article
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article