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?
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 Manager | SSM Parameter Store (Standard) | |
|---|---|---|
| Storage | $0.40/secret/month | Free |
| API calls | $0.05/10K calls | Free (up to 10K/sec) |
| Encryption | KMS (same) | KMS (same) |
| Max size | 64KB | 8KB (Advanced: 8KB) |
| Rotation | Built-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:
# 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
# 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
# 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:
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:
# 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']
// 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:
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. β
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-basedGetParametersByPathto 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 Count | Secrets Manager/Year | SSM Parameter Store/Year | Annual 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! π¬
Top comments (0)