DEV Community

Cover image for Stop Overpaying for Secrets You Never Rotate: Migrate to SSM Parameter Store with Terraform πŸ”
Suhas Mallesh
Suhas Mallesh

Posted on • Edited on

Stop Overpaying for Secrets You Never Rotate: Migrate to SSM Parameter Store with Terraform πŸ”

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
Enter fullscreen mode Exit fullscreen mode

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:*:*:*"
      }
    ]
  })
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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. πŸ’°
Enter fullscreen mode Exit fullscreen mode

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']
Enter fullscreen mode Exit fullscreen mode
// 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;
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

⚑ 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
Enter fullscreen mode Exit fullscreen mode

⚠️ 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 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)