Managing Secrets in ECS: Parameter Store vs. Secrets Manager - A Complete Guide
Key takeaways
- Parameter Store costs $0.05/10K API calls; Secrets Manager costs $0.40/secret/month plus $0.05/10K API calls
- Secrets Manager provides automatic rotation for RDS, Redshift, and DocumentDB; Parameter Store requires custom Lambda functions
- Both integrate natively with ECS task definitions for secure secret injection
- Parameter Store supports hierarchical organization and cross-account access; Secrets Manager excels at credential rotation
- Choose Parameter Store for configuration and API keys; Secrets Manager for database credentials requiring rotation
Your ECS task fails to start. The error message is cryptic: "CannotPullContainerError: pull access denied." You check the ECR credentials—they're hardcoded in the task definition from six months ago when the team was moving fast. The credentials rotated. Your production deployment is blocked.
This scenario plays out across organizations managing secrets in container workloads. The solution isn't just storing secrets securely—it's building a secrets management strategy that balances security, cost, operational complexity, and developer experience.
AWS provides two services for secrets management: Systems Manager Parameter Store and Secrets Manager. Both integrate with ECS, both support encryption, and both solve the "don't hardcode credentials" problem. But they're designed for different use cases, with different cost models and operational characteristics.
This guide provides a comprehensive comparison to help you choose the right service—or combination of both—for your ECS workloads.
Understanding the Services
AWS Systems Manager Parameter Store
Parameter Store is a hierarchical key-value store designed for configuration management and secrets storage.
Key Characteristics:
- Pricing: $0.05 per 10,000 API calls (standard parameters); advanced parameters cost $0.05/parameter/month
- Value size: 4KB (standard), 8KB (advanced)
- Encryption: Optional KMS encryption
- Versioning: Built-in version history
- Organization: Hierarchical paths (e.g.,
/prod/api/database/password) - Rotation: Manual or custom Lambda functions
- Cross-account: Supports cross-account access with resource policies
Best for:
- Application configuration
- API keys and tokens
- Feature flags
- Non-rotating secrets
- Cost-sensitive workloads
AWS Secrets Manager
Secrets Manager is a dedicated secrets management service with built-in rotation capabilities.
Key Characteristics:
- Pricing: $0.40/secret/month + $0.05 per 10,000 API calls
- Value size: 65,536 bytes (64KB)
- Encryption: Always encrypted with KMS
- Versioning: Automatic version management with staging labels
- Organization: Flat namespace with tags
- Rotation: Automatic rotation with pre-built Lambda functions for RDS, Redshift, DocumentDB
- Cross-region replication: Built-in multi-region replication
Best for:
- Database credentials
- Third-party API credentials requiring rotation
- Secrets requiring automatic rotation
- Multi-region deployments
- Compliance requirements (SOC 2, PCI-DSS)
Cost Analysis
Scenario 1: Small Application (10 Secrets)
Parameter Store:
10 secrets (standard parameters)
1,000 retrievals/day = 30,000/month
Cost: 30,000 / 10,000 × $0.05 = $0.15/month
Secrets Manager:
10 secrets × $0.40/month = $4.00
30,000 API calls / 10,000 × $0.05 = $0.15
Total: $4.15/month
Difference: Secrets Manager costs $4.00 more/month (2,667% increase)
Scenario 2: Medium Application (100 Secrets)
Parameter Store:
100 secrets (standard parameters)
10,000 retrievals/day = 300,000/month
Cost: 300,000 / 10,000 × $0.05 = $1.50/month
Secrets Manager:
100 secrets × $0.40/month = $40.00
300,000 API calls / 10,000 × $0.05 = $1.50
Total: $41.50/month
Difference: Secrets Manager costs $40.00 more/month
Scenario 3: Large Application (1,000 Secrets, High Traffic)
Parameter Store:
1,000 secrets (standard parameters)
100,000 retrievals/day = 3,000,000/month
Cost: 3,000,000 / 10,000 × $0.05 = $15.00/month
Secrets Manager:
1,000 secrets × $0.40/month = $400.00
3,000,000 API calls / 10,000 × $0.05 = $15.00
Total: $415.00/month
Difference: Secrets Manager costs $400.00 more/month
Cost Optimization: Caching
Both services support client-side caching to reduce API calls.
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
// Simple in-memory cache
const secretCache = new Map<string, { value: string; expiry: number }>();
async function getSecret(secretName: string, ttl: number = 300): Promise<string> {
const cached = secretCache.get(secretName);
if (cached && cached.expiry > Date.now()) {
return cached.value;
}
const client = new SecretsManagerClient({});
const response = await client.send(new GetSecretValueCommand({ SecretId: secretName }));
const value = response.SecretString!;
secretCache.set(secretName, {
value,
expiry: Date.now() + (ttl * 1000),
});
return value;
}Cost impact with 5-minute cache:
- Before: 100,000 calls/day = 3M/month = $15
- After: 100,000 / (5 * 12) = 1,667 calls/day = 50,000/month = $0.25
- Savings: $14.75/month (98% reduction)
ECS Integration Patterns
Pattern 1: Task Definition Secrets (Recommended)
ECS supports native secret injection via task definitions.
Using Parameter Store:
{
"family": "api-service",
"containerDefinitions": [{
"name": "app",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/api:latest",
"secrets": [
{
"name": "DATABASE_PASSWORD",
"valueFrom": "arn:aws:ssm:us-east-1:123456789012:parameter/prod/api/db/password"
},
{
"name": "API_KEY",
"valueFrom": "arn:aws:ssm:us-east-1:123456789012:parameter/prod/api/external/key"
}
],
"environment": [
{
"name": "DATABASE_HOST",
"value": "db.example.com"
}
]
}],
"executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole"
}Using Secrets Manager:
{
"family": "api-service",
"containerDefinitions": [{
"name": "app",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/api:latest",
"secrets": [
{
"name": "DATABASE_PASSWORD",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/api/db-AbCdEf:password::"
},
{
"name": "DATABASE_USERNAME",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/api/db-AbCdEf:username::"
}
]
}],
"executionRoleArn": "arn:aws:iam::123456789012:role/ecsTaskExecutionRole"
}Terraform Configuration:
# Parameter Store secret
resource "aws_ssm_parameter" "db_password" {
name = "/prod/api/db/password"
description = "Database password for API service"
type = "SecureString"
value = var.db_password
tags = {
Environment = "production"
Service = "api"
}
}
# Secrets Manager secret
resource "aws_secretsmanager_secret" "db_credentials" {
name = "prod/api/db"
description = "Database credentials for API service"
tags = {
Environment = "production"
Service = "api"
}
}
resource "aws_secretsmanager_secret_version" "db_credentials" {
secret_id = aws_secretsmanager_secret.db_credentials.id
secret_string = jsonencode({
username = var.db_username
password = var.db_password
host = aws_db_instance.main.endpoint
port = 5432
})
}
# ECS Task Execution Role with permissions
resource "aws_iam_role" "ecs_execution" {
name = "ecsTaskExecutionRole"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}]
})
}
# Parameter Store access
resource "aws_iam_role_policy" "parameter_store_access" {
name = "parameter-store-access"
role = aws_iam_role.ecs_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"ssm:GetParameters",
"ssm:GetParameter",
]
Resource = [
"arn:aws:ssm:${var.region}:${var.account_id}:parameter/prod/api/*"
]
}, {
Effect = "Allow"
Action = [
"kms:Decrypt"
]
Resource = [
aws_kms_key.secrets.arn
]
}]
})
}
# Secrets Manager access
resource "aws_iam_role_policy" "secrets_manager_access" {
name = "secrets-manager-access"
role = aws_iam_role.ecs_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue"
]
Resource = [
aws_secretsmanager_secret.db_credentials.arn
]
}, {
Effect = "Allow"
Action = [
"kms:Decrypt"
]
Resource = [
aws_kms_key.secrets.arn
]
}]
})
}
# ECS Task Definition
resource "aws_ecs_task_definition" "api" {
family = "api-service"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.ecs_execution.arn
task_role_arn = aws_iam_role.ecs_task.arn
container_definitions = jsonencode([{
name = "app"
image = "${aws_ecr_repository.api.repository_url}:latest"
secrets = [
{
name = "DATABASE_PASSWORD"
valueFrom = "${aws_ssm_parameter.db_password.arn}"
},
{
name = "DATABASE_CREDENTIALS"
valueFrom = "${aws_secretsmanager_secret.db_credentials.arn}"
}
]
environment = [
{
name = "NODE_ENV"
value = "production"
}
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = "/ecs/api-service"
"awslogs-region" = var.region
"awslogs-stream-prefix" = "ecs"
}
}
}])
}Pattern 2: Application-Level Secret Retrieval
For dynamic secret retrieval or runtime updates without container restart.
Parameter Store Client:
import { SSMClient, GetParameterCommand, GetParametersByPathCommand } from '@aws-sdk/client-ssm';
const ssm = new SSMClient({});
// Get single parameter
export async function getParameter(name: string): Promise<string> {
const response = await ssm.send(new GetParameterCommand({
Name: name,
WithDecryption: true,
}));
return response.Parameter!.Value!;
}
// Get all parameters under a path
export async function getParametersByPath(path: string): Promise<Record<string, string>> {
const response = await ssm.send(new GetParametersByPathCommand({
Path: path,
Recursive: true,
WithDecryption: true,
}));
const params: Record<string, string> = {};
for (const param of response.Parameters || []) {
// Extract parameter name without path prefix
const key = param.Name!.split('/').pop()!;
params[key] = param.Value!;
}
return params;
}
// Usage
const dbPassword = await getParameter('/prod/api/db/password');
const allApiSecrets = await getParametersByPath('/prod/api/');Secrets Manager Client:
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const secretsManager = new SecretsManagerClient({});
// Get secret value
export async function getSecret(secretId: string): Promise<any> {
const response = await secretsManager.send(new GetSecretValueCommand({
SecretId: secretId,
}));
// Parse JSON secret
if (response.SecretString) {
return JSON.parse(response.SecretString);
}
// Handle binary secret
if (response.SecretBinary) {
return Buffer.from(response.SecretBinary).toString('utf-8');
}
throw new Error('Secret not found');
}
// Usage
const dbCredentials = await getSecret('prod/api/db');
console.log(`Connecting to ${dbCredentials.host}:${dbCredentials.port}`);Pattern 3: Hybrid Approach (Best Practice)
Use both services for their strengths.
# Secrets Manager for database credentials (automatic rotation)
resource "aws_secretsmanager_secret" "rds_master" {
name = "prod/rds/master"
description = "RDS master credentials with automatic rotation"
}
resource "aws_secretsmanager_secret_rotation" "rds_master" {
secret_id = aws_secretsmanager_secret.rds_master.id
rotation_lambda_arn = aws_lambda_function.rotate_secret.arn
rotation_rules {
automatically_after_days = 30
}
}
# Parameter Store for configuration and API keys
resource "aws_ssm_parameter" "stripe_api_key" {
name = "/prod/api/stripe/api_key"
type = "SecureString"
value = var.stripe_api_key
}
resource "aws_ssm_parameter" "sendgrid_api_key" {
name = "/prod/api/sendgrid/api_key"
type = "SecureString"
value = var.sendgrid_api_key
}
resource "aws_ssm_parameter" "feature_flags" {
name = "/prod/api/features/experimental_checkout"
type = "String"
value = "enabled"
}Secret Rotation Strategies
Secrets Manager: Automatic Rotation
RDS/Aurora Rotation (Built-in):
resource "aws_secretsmanager_secret" "rds_credentials" {
name = "prod/rds/app-user"
}
resource "aws_secretsmanager_secret_version" "rds_credentials" {
secret_id = aws_secretsmanager_secret.rds_credentials.id
secret_string = jsonencode({
username = "app_user"
password = random_password.db_password.result
engine = "postgres"
host = aws_db_instance.main.endpoint
port = 5432
dbname = "production"
})
}
# Automatic rotation every 30 days
resource "aws_secretsmanager_secret_rotation" "rds_credentials" {
secret_id = aws_secretsmanager_secret.rds_credentials.id
rotation_lambda_arn = aws_serverlessapplicationrepository_cloudformation_stack.rotator.outputs.RotationLambdaARN
rotation_rules {
automatically_after_days = 30
}
}
# Serverless Application Repository rotation function
resource "aws_serverlessapplicationrepository_cloudformation_stack" "rotator" {
name = "SecretsManagerRDSPostgreSQLRotationSingleUser"
application_id = "arn:aws:serverlessrepo:us-east-1:297356227924:applications/SecretsManagerRDSPostgreSQLRotationSingleUser"
parameters = {
endpoint = "https://secretsmanager.us-east-1.amazonaws.com"
functionName = "SecretsManagerRDSPostgreSQLRotation"
vpcSubnetIds = join(",", [aws_subnet.private_a.id, aws_subnet.private_b.id])
vpcSecurityGroupIds = aws_security_group.lambda.id
}
capabilities = [
"CAPABILITY_IAM",
"CAPABILITY_RESOURCE_POLICY",
]
}Custom Rotation Function:
// Lambda function for custom secret rotation
import { SecretsManagerClient, GetSecretValueCommand,
PutSecretValueCommand, UpdateSecretVersionStageCommand } from '@aws-sdk/client-secrets-manager';
const secretsManager = new SecretsManagerClient({});
export const handler = async (event: any) => {
const token = event.Token;
const secretId = event.SecretId;
const step = event.Step;
switch (step) {
case 'createSecret':
await createSecret(secretId, token);
break;
case 'setSecret':
await setSecret(secretId, token);
break;
case 'testSecret':
await testSecret(secretId, token);
break;
case 'finishSecret':
await finishSecret(secretId, token);
break;
default:
throw new Error(`Invalid step: ${step}`);
}
};
async function createSecret(secretId: string, token: string) {
// Generate new secret value
const newPassword = generateSecurePassword();
// Store new secret with AWSPENDING label
await secretsManager.send(new PutSecretValueCommand({
SecretId: secretId,
ClientRequestToken: token,
SecretString: JSON.stringify({ password: newPassword }),
VersionStages: ['AWSPENDING'],
}));
}
async function setSecret(secretId: string, token: string) {
// Get the pending secret
const response = await secretsManager.send(new GetSecretValueCommand({
SecretId: secretId,
VersionId: token,
VersionStage: 'AWSPENDING',
}));
const newSecret = JSON.parse(response.SecretString!);
// Update the external service with new credentials
await updateExternalServiceCredentials(newSecret.password);
}
async function testSecret(secretId: string, token: string) {
// Get the pending secret
const response = await secretsManager.send(new GetSecretValueCommand({
SecretId: secretId,
VersionId: token,
VersionStage: 'AWSPENDING',
}));
const newSecret = JSON.parse(response.SecretString!);
// Test the new credentials
const isValid = await testExternalServiceConnection(newSecret.password);
if (!isValid) {
throw new Error('New secret failed validation');
}
}
async function finishSecret(secretId: string, token: string) {
// Move AWSCURRENT label to new version
await secretsManager.send(new UpdateSecretVersionStageCommand({
SecretId: secretId,
VersionStage: 'AWSCURRENT',
MoveToVersionId: token,
RemoveFromVersionId: await getCurrentVersionId(secretId),
}));
}
function generateSecurePassword(): string {
const crypto = require('crypto');
return crypto.randomBytes(32).toString('base64');
}Parameter Store: Manual Rotation
Parameter Store doesn't have built-in rotation, but you can build custom workflows.
// Custom rotation script for Parameter Store
import { SSMClient, PutParameterCommand, GetParameterHistoryCommand } from '@aws-sdk/client-ssm';
import { KMSClient, GenerateRandomCommand } from '@aws-sdk/client-kms';
const ssm = new SSMClient({});
const kms = new KMSClient({});
async function rotateParameter(parameterName: string): Promise<void> {
// Generate new secret value
const randomData = await kms.send(new GenerateRandomCommand({
NumberOfBytes: 32,
}));
const newValue = Buffer.from(randomData.Plaintext!).toString('base64');
// Update parameter (creates new version automatically)
await ssm.send(new PutParameterCommand({
Name: parameterName,
Value: newValue,
Type: 'SecureString',
Overwrite: true,
Description: `Rotated on ${new Date().toISOString()}`,
}));
console.log(`Rotated parameter: ${parameterName}`);
// Trigger application reload or rolling deployment
await triggerECSDeployment();
}
async function triggerECSDeployment(): Promise<void> {
// Force new deployment to pick up updated parameter
const { ECSClient, UpdateServiceCommand } = require('@aws-sdk/client-ecs');
const ecs = new ECSClient({});
await ecs.send(new UpdateServiceCommand({
cluster: 'production',
service: 'api-service',
forceNewDeployment: true,
}));
}Hierarchical Organization (Parameter Store)
Parameter Store's hierarchical structure enables elegant organization.
/prod/
├── api/
│ ├── database/
│ │ ├── host
│ │ ├── port
│ │ ├── username
│ │ └── password
│ ├── redis/
│ │ ├── host
│ │ └── port
│ └── external/
│ ├── stripe_key
│ └── sendgrid_key
├── worker/
│ └── queue/
│ ├── url
│ └── region
└── shared/
├── kms_key_id
└── log_level
Terraform:
# Database configuration
resource "aws_ssm_parameter" "db_host" {
name = "/prod/api/database/host"
type = "String"
value = aws_db_instance.main.endpoint
}
resource "aws_ssm_parameter" "db_password" {
name = "/prod/api/database/password"
type = "SecureString"
value = random_password.db_password.result
}
# Retrieve all database parameters
data "aws_ssm_parameters_by_path" "db_config" {
path = "/prod/api/database"
with_decryption = true
}Application Code:
// Load all parameters under a path
const dbConfig = await getParametersByPath('/prod/api/database');
const pool = new Pool({
host: dbConfig.host,
port: parseInt(dbConfig.port),
user: dbConfig.username,
password: dbConfig.password,
database: 'production',
});Cross-Account Access
Parameter Store Cross-Account
# Account A: Create parameter with resource policy
resource "aws_ssm_parameter" "shared_api_key" {
name = "/shared/api/external_service_key"
type = "SecureString"
value = var.api_key
}
resource "aws_ssm_parameter_policy" "cross_account" {
name = aws_ssm_parameter.shared_api_key.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Sid = "AllowCrossAccountAccess"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::222222222222:root"
}
Action = [
"ssm:GetParameter",
"ssm:GetParameters"
]
Resource = aws_ssm_parameter.shared_api_key.arn
}]
})
}
# Account B: IAM policy to access parameter in Account A
resource "aws_iam_role_policy" "cross_account_parameter" {
role = aws_iam_role.ecs_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"ssm:GetParameter",
"ssm:GetParameters"
]
Resource = "arn:aws:ssm:us-east-1:111111111111:parameter/shared/api/*"
}]
})
}Secrets Manager Cross-Region Replication
resource "aws_secretsmanager_secret" "db_credentials" {
name = "prod/api/db"
replica {
region = "us-west-2"
}
replica {
region = "eu-west-1"
kms_key_id = aws_kms_key.eu_west_1.id
}
}Decision Matrix
| Requirement | Parameter Store | Secrets Manager |
|---|---|---|
| Database credentials with rotation | ❌ Manual | ✅ Automatic |
| API keys (no rotation) | ✅ Cost-effective | ⚠️ Works but expensive |
| Configuration values | ✅ Designed for this | ❌ Overkill |
| Multi-region secrets | ⚠️ Manual replication | ✅ Built-in |
| Budget-conscious | ✅ $0.15 for 30K calls | ❌ $0.40/secret/month |
| Hierarchical organization | ✅ Path-based | ❌ Flat namespace |
| Large secrets (>4KB) | ⚠️ 8KB max (advanced) | ✅ 64KB |
| Compliance (audit trail) | ✅ CloudTrail | ✅ CloudTrail + versioning |
| Cross-account access | ✅ Resource policies | ✅ Resource policies |
Best Practices
1. Use Least Privilege IAM Policies
# ❌ Bad: Overly permissive
resource "aws_iam_role_policy" "bad_secrets_access" {
role = aws_iam_role.ecs_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "secretsmanager:*"
Resource = "*"
}]
})
}
# ✅ Good: Specific resources and actions
resource "aws_iam_role_policy" "good_secrets_access" {
role = aws_iam_role.ecs_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"secretsmanager:GetSecretValue"
]
Resource = [
"arn:aws:secretsmanager:${var.region}:${var.account_id}:secret:prod/api/*"
]
Condition = {
StringEquals = {
"secretsmanager:VersionStage" = "AWSCURRENT"
}
}
}]
})
}2. Enable Encryption with Customer-Managed KMS Keys
resource "aws_kms_key" "secrets" {
description = "KMS key for secrets encryption"
deletion_window_in_days = 30
enable_key_rotation = true
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "Enable IAM User Permissions"
Effect = "Allow"
Principal = {
AWS = "arn:aws:iam::${var.account_id}:root"
}
Action = "kms:*"
Resource = "*"
},
{
Sid = "Allow ECS Task Execution Role"
Effect = "Allow"
Principal = {
AWS = aws_iam_role.ecs_execution.arn
}
Action = [
"kms:Decrypt",
"kms:DescribeKey"
]
Resource = "*"
}
]
})
}
resource "aws_secretsmanager_secret" "db_credentials" {
name = "prod/api/db"
kms_key_id = aws_kms_key.secrets.id
}3. Implement Secret Caching
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import NodeCache from 'node-cache';
const secretsManager = new SecretsManagerClient({});
const cache = new NodeCache({ stdTTL: 300, checkperiod: 60 });
export async function getCachedSecret(secretId: string): Promise<any> {
// Check cache first
const cached = cache.get(secretId);
if (cached) {
return cached;
}
// Fetch from Secrets Manager
const response = await secretsManager.send(new GetSecretValueCommand({
SecretId: secretId,
}));
const secret = JSON.parse(response.SecretString!);
// Cache for 5 minutes
cache.set(secretId, secret);
return secret;
}4. Monitor Secret Access
resource "aws_cloudwatch_log_metric_filter" "unauthorized_secret_access" {
name = "unauthorized-secret-access"
log_group_name = "/aws/cloudtrail"
pattern = "{ ($.eventName = GetSecretValue || $.eventName = GetParameter) && $.errorCode = AccessDenied }"
metric_transformation {
name = "UnauthorizedSecretAccess"
namespace = "Security/Secrets"
value = "1"
}
}
resource "aws_cloudwatch_metric_alarm" "secret_access_denied" {
alarm_name = "unauthorized-secret-access-detected"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "UnauthorizedSecretAccess"
namespace = "Security/Secrets"
period = 300
statistic = "Sum"
threshold = 0
alarm_description = "Alert on unauthorized secret access attempts"
alarm_actions = [aws_sns_topic.security_alerts.arn]
}5. Rotate Secrets Regularly
# Secrets Manager: Built-in rotation
resource "aws_secretsmanager_secret_rotation" "api_key" {
secret_id = aws_secretsmanager_secret.api_key.id
rotation_lambda_arn = aws_lambda_function.rotate_api_key.arn
rotation_rules {
automatically_after_days = 90
}
}
# Parameter Store: EventBridge-triggered Lambda
resource "aws_cloudwatch_event_rule" "rotate_parameters" {
name = "rotate-parameters-quarterly"
description = "Trigger parameter rotation every 90 days"
schedule_expression = "rate(90 days)"
}
resource "aws_cloudwatch_event_target" "rotate_parameters" {
rule = aws_cloudwatch_event_rule.rotate_parameters.name
target_id = "RotateParametersLambda"
arn = aws_lambda_function.rotate_parameters.arn
}Conclusion: Choose Based on Use Case
The choice between Parameter Store and Secrets Manager isn't binary—most production architectures benefit from both:
Use Secrets Manager for:
- Database credentials requiring automatic rotation
- Secrets needing multi-region replication
- Compliance-heavy environments requiring audit trails
- Large secret values (>4KB)
Use Parameter Store for:
- Application configuration
- Feature flags
- API keys that don't rotate
- Cost-sensitive workloads
- Hierarchical secret organization
The cost difference is significant at scale ($400/month for 1,000 secrets), but the operational benefits of automatic rotation often justify Secrets Manager for database credentials while using Parameter Store for everything else.
Start with Parameter Store for configuration and non-rotating secrets. Introduce Secrets Manager selectively for credentials requiring rotation. Monitor costs and adjust based on your actual usage patterns.
Action Items:
- Audit current secret storage (environment variables, hardcoded values, config files)
- Categorize secrets by rotation requirements
- Implement Parameter Store for configuration and non-rotating secrets
- Migrate database credentials to Secrets Manager with automatic rotation
- Update ECS task definitions to use secret injection
- Configure IAM policies with least privilege access
- Enable CloudTrail monitoring for secret access patterns