AWS SSM Parameter Store vs Secrets Manager: Stop Committing .env Files (2026)
Published · Updated
Enterprise security guides universally assume you have a Chief Information Security Officer (CISO), an automated compliance auditing system, and a dedicated platform security team to manage your encryption keys. But when you are a solo developer, an independent contractor, or a bootstrapped founder, over-engineered security patterns don’t just slow you down—they create an operational maze that introduces human error.
You do not need an enterprise-grade secrets governance framework. You need a practical, bulletproof workflow that completely eliminates hardcoded credentials, survives a leaked repo, and does not eat up your weekends with configuration maintenance.
The Solo Trap: .env and the .gitignore Illusion
When you are the only person writing code, speed and convenience usually win. It starts innocently: you drop an API key into a local .env file, promise yourself you will clean it up before moving to production, and then accidentally stage and commit the file to your repository.
Local machine
├── Commit 1: .env with live DB password → secret in git history forever
└── Commit 2: add .env to .gitignore → file hidden, history unchanged
GitHub → scrapers scan history → compromised credentials
You cannot fix this with another commit. Because Git functions as a directed acyclic graph (DAG) of snapshots, simply adding .env to your .gitignore and pushing a follow-up commit only hides the file from the current directory tree view. The plaintext key remains permanently baked into your historical commit logs. Automated credential-scraping bots monitor public history feeds constantly; if a repository is ever made public or a personal access token is leaked, these bots will parse your entire commit history in milliseconds.
If you ever commit a raw secret, assume it is instantly compromised. Take these steps immediately:
- Rotate the credentials at the source provider (e.g., AWS, Stripe, SendGrid).
- Scrub the repository history using dedicated tools like
git-filter-repoor BFG Repo-Cleaner. - Never rely on a follow-up commit to fix a security leakage. The optimal approach is ensuring production secrets never touch your local disk in plaintext format.
SSM Parameter Store vs. Secrets Manager
AWS provides two native utilities to pull secrets out of your application source code. For a solo developer, choosing between them comes down to a straightforward calculation of monthly financial overhead versus automated feature requirements:
| Feature | SSM SecureString | Secrets Manager |
|---|---|---|
| Storage Cost | Free (standard params) | $0.40/secret/month |
| API Calls | Free (standard throughput) | $0.05 per 10k requests |
| Auto Rotation | Custom Lambda only | Native templates (e.g., RDS) |
| Best For | API keys, flags, app config | RDS passwords, rotation compliance |
The Solo Default: SSM SecureString
For almost every standard application variable, third-party API token, or configuration flag, SSM Parameter Store is your default choice. By using the SecureString parameter type, AWS encrypts your data at rest using an AWS Key Management Service (KMS) customer-managed key or default AWS managed key. It costs nothing to store up to 10,000 parameters per region, keeping your baseline cloud utility bill at zero.
When to Choose Secrets Manager
You should opt for AWS Secrets Manager primarily when you are using an AWS database engine (such as Amazon RDS) and want the system to handle complex password synchronization completely on autopilot. Secrets Manager charges $0.40 per secret per month. While that sounds negligible, if your application requires 40 distinct configuration keys, using Secrets Manager indiscriminately inflicts an unnecessary $16 per month (~₹1,300 INR) tax on your architecture.
On Kubernetes Note: If you deploy your applications to an EKS or self-hosted Kubernetes cluster, consider utilizing Sealed Secrets. This mechanism lets you safely encrypt your sensitive keys into an encrypted file that can be committed directly to Git; only the controller running inside your live cluster possesses the private key required to decrypt it. See also ConfigMaps & Secrets production patterns.
Minimal IAM: One Role, One Path
Do not fall into the habit of assigning broad AdministratorAccess or wide wildcards (*) to your application’s computing identities. Instead, attach an execution role to your EC2 instance, ECS task, or AWS Lambda function that limits reading access exclusively to your application’s specific configuration path.
Adopt a strict naming hierarchy for your parameters based on paths:
/[environment]/[app-name]/[secret-name]
For example, your production backend database string should live at:
/production/api/db_password
By mapping your variables to a clean path structure, you can enforce a lean, least-privilege IAM security policy that looks like this:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath"
],
"Resource": "arn:aws:ssm:ap-south-1:123456789012:parameter/production/my-app/*"
}]
}
This single policy ensures your application execution container can systematically pull its required environment configurations at boot via ssm:GetParametersByPath while remaining completely sandboxed from any other infrastructure parameters inside your cloud account.
Rotation: What to Automate Solo
Enterprise compliance standards dictate rotating every operational credential every 30 to 90 days. For a single engineer, trying to write and maintain custom AWS Lambda rotation scripts for dozens of distinct third-party SaaS vendors is a massive time sink that introduces breaking synchronization risks.
Prioritize your rotation workloads pragmatically:
- RDS and Core Infrastructure Passwords: Turn on native AWS Secrets Manager auto-rotation. Because AWS manages both sides of the handshake (the database authentication layer and the storage vault), it runs seamlessly on a set-and-forget cadence with zero maintenance code required from you.
- Third-Party API Keys (Stripe, SendGrid, Auth0): Keep these on a manual rotation cycle. Trigger a manual rotation event exclusively during major operational updates, when an external vendor experiences a public security breach, or during your annual infrastructure cleanup windows.
CI/CD: Inject at Runtime, Not Build Time
When constructing deployment execution paths within environments like GitHub Actions, keep your application build pipelines completely isolated from your production application secrets.
1. Leverage OpenID Connect (OIDC)
Stop generating long-lived IAM user access keys (AWS_ACCESS_KEY_ID) and storing them as static strings in your GitHub repository settings. Instead, configure an IAM Identity Provider to establish an OIDC trust relationship between GitHub and AWS. This enables GitHub Actions to request short-lived, temporary security tokens that expire automatically after your deployment job concludes.
# GitHub Actions — prefer OIDC over static keys when possible
permissions:
id-token: write
contents: read
2. Runtime Injection
Never pack actual production database credentials or payment keys into your Docker container images or compiled application codebases during the CI build phase. Doing so means anyone with read access to your container registry instantly owns your production database.
Instead, compile a clean, stateless application artifact. When the container boots up in your AWS production environment, have your application framework fetch its required runtime configuration keys directly from the AWS SSM Parameter Store pathway.
Hands-on: Bootstrap SSM for a new project
#!/bin/bash
# setup-ssm.sh — create a clean parameter hierarchy and IAM role
APP_NAME="myapp"
ENV="production"
REGION="ap-south-1"
# 1. Create parameter hierarchy in SSM
aws ssm put-parameter \
--name "/${ENV}/${APP_NAME}/db_password" \
--value "$(openssl rand -base64 32)" \
--type SecureString \
--key-id alias/aws/ssm \
--region ${REGION}
aws ssm put-parameter \
--name "/${ENV}/${APP_NAME}/api_key_stripe" \
--value "sk_live_xxxx" \
--type SecureString \
--region ${REGION}
# 2. Create IAM role for EC2/ECS/Lambda
cat > trust-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Service": "ec2.amazonaws.com"
},
"Action": "sts:AssumeRole"
}]
}
EOF
aws iam create-role \
--role-name ${APP_NAME}-app-role \
--assume-role-policy-document file://trust-policy.json
# 3. Attach least-privilege policy
cat > ssm-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"ssm:GetParameter",
"ssm:GetParameters",
"ssm:GetParametersByPath"
],
"Resource": "arn:aws:ssm:${REGION}:$(aws sts get-caller-identity --query Account --output text):parameter/${ENV}/${APP_NAME}/*"
}, {
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": "arn:aws:kms:${REGION}:$(aws sts get-caller-identity --query Account --output text):key/*",
"Condition": {
"StringEquals": {
"kms:ViaService": "ssm.${REGION}.amazonaws.com"
}
}
}]
}
EOF
aws iam put-role-policy \
--role-name ${APP_NAME}-app-role \
--policy-name SSMParameterAccess \
--policy-document file://ssm-policy.json
echo "✓ SSM hierarchy and IAM role created"
aws ssm get-parameters-by-path \
--path "/${ENV}/${APP_NAME}" \
--recursive \
--region ${REGION}
Fetch secrets at app startup (Node.js example)
// src/config.ts — load secrets once at startup, not per request
import AWS from 'aws-sdk';
const ssm = new AWS.SSM({ region: 'ap-south-1' });
async function loadSecrets(env: string, appName: string) {
try {
const response = await ssm.getParametersByPath({
Path: `/${env}/${appName}`,
Recursive: true,
WithDecryption: true,
}).promise();
const config: Record<string, string> = {};
response.Parameters?.forEach((param) => {
const key = param.Name?.split('/').pop() || '';
config[key] = param.Value || '';
});
return config;
} catch (err) {
console.error('Failed to load secrets:', err);
throw new Error('SSM initialization failed');
}
}
export const appConfig = await loadSecrets(
process.env.NODE_ENV || 'staging',
'myapp'
);
// Use in your app
export const DB_PASSWORD = appConfig.db_password;
export const STRIPE_KEY = appConfig.api_key_stripe;
Important: Load secrets once at startup, not inside request handlers. Each SSM call costs ~$0.05 per 10k requests; re-fetching per request kills free tier instantly.
Python (with caching layer)
# config.py — load and cache secrets
import boto3
import os
from functools import lru_cache
ssm_client = boto3.client('ssm', region_name='ap-south-1')
@lru_cache(maxsize=1)
def load_secrets(env: str, app_name: str) -> dict:
"""Load secrets from SSM and cache for app lifetime"""
try:
response = ssm_client.get_parameters_by_path(
Path=f'/{env}/{app_name}',
Recursive=True,
WithDecryption=True,
)
config = {}
for param in response['Parameters']:
key = param['Name'].split('/')[-1]
config[key] = param['Value']
return config
except Exception as e:
print(f'SSM load failed: {e}')
raise
config = load_secrets(os.getenv('ENV', 'staging'), 'myapp')
DB_PASSWORD = config['db_password']
STRIPE_KEY = config['api_key_stripe']
GitHub Actions: OIDC + runtime injection
Storing static AWS_ACCESS_KEY_ID in GitHub Secrets is convenient but risky — the key lives forever. Use OIDC for short-lived tokens instead:
1. Set up AWS side (IAM Identity Provider):
# Create OIDC identity provider
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 \
--client-id-list sts.amazonaws.com
# Create role for GitHub Actions
cat > github-oidc-trust.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:YOUR_ORG/YOUR_REPO:*"
}
}
}]
}
EOF
aws iam create-role \
--role-name github-actions-deploy \
--assume-role-policy-document file://github-oidc-trust.json
2. Attach the app deployment policy:
# Reuse the SSM policy from earlier
aws iam put-role-policy \
--role-name github-actions-deploy \
--policy-name AppSecrets \
--policy-document file://ssm-policy.json
3. GitHub Actions workflow (no static keys stored):
name: Deploy with OIDC
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::ACCOUNT_ID:role/github-actions-deploy
aws-region: ap-south-1
- name: Pull app secrets and deploy
run: |
# App fetches from SSM at startup (see Node/Python examples)
npm run build
npm run deploy
Advantages:
- No static IAM keys in GitHub
- Token expires after job (default 1 hour, set
ACTIONS_ID_TOKEN_REQUEST_TOKEN_EXPIRATION_MINUTESif needed) - Revoke with a single IAM role update, affects all future deployments
- Audit trail: CloudTrail logs which GitHub Actions job assumed the role
Emergency: You committed a secret
Timeline: Assume the credential is live on GitHub within 5 minutes.
-
Rotate immediately (don’t debug, just rotate):
# AWS — rotate access keys aws iam delete-access-key --access-key-id AKIAIOSFODNN7EXAMPLE # RDS password aws rds modify-db-instance --db-instance-identifier mydb \ --master-user-password "$(openssl rand -base64 32)" --apply-immediately -
Scrub history (pick one):
# Option A: git-filter-repo (recommended) git filter-repo --invert-paths --path .env # Option B: BFG (faster for large repos) bfg --delete-files .env # After rewriting, force-push (nuclear option) git push origin --force-with-lease --all -
Force-push cleanup to GitHub (breaks clones of the old history):
git push origin --force-with-lease main # Notify team: "History rewritten, please git clone fresh" -
Check GitHub’s secret scanning — go to Settings → Security → Secret scanning and verify GitHub flagged the exposure. If using push protection, it should have blocked the commit.
New Project Checklist
Before you write your first line of functional application logic, run through this baseline checklist to secure your codebase from day zero:
-
Establish the Git Guard: Verify your
.gitignorefile explicitly blocks.env,*.pem,*.key,*.json(esp. service accounts), and local.aws/directories..env .env.local .env.*.local *.pem *.key credentials.json .aws/credentials .aws/config -
Define the Path Prefix: Map out your configuration environment paths clearly:
/staging/myapp/db_password /production/myapp/db_password /production/myapp/api_key_stripe -
Decouple Local Development: Use
dotenvfiles locally (never committed), mocking libraries like LocalStack for AWS, or Docker Compose with service containers. Ensure no production fallback strings in code:// ❌ BAD const dbPassword = process.env.DB_PASSWORD || 'prod-secret-hardcoded'; // ✅ GOOD const dbPassword = process.env.DB_PASSWORD; if (!dbPassword) throw new Error('DB_PASSWORD not set — local dev needs .env'); -
Enable Repository Protection: GitHub Settings → Code security & analysis:
- Enable secret scanning (free, flags exposed secrets in issues)
- Enable push protection (blocks commits with detected secrets locally)
- Set branch protection to require reviews before merge
-
Set up OIDC for CI/CD (GitHub Actions, GitLab CI, etc.) instead of static API keys.
-
Document the rotation schedule: Who rotates what, and when?
## Secrets Rotation Schedule - RDS passwords: Auto-rotated via Secrets Manager (30 days) - Stripe API keys: Manual rotation on vendor breaches or offboarding - GitHub PAT: Rotate before employee departure
Related guides
- GitOps vs Traditional CI/CD — Secure deployments with GitHub Actions OIDC
- How to Right-Size EC2 — Cost optimization using least-privilege IAM
- Kubernetes ConfigMaps & Secrets pitfalls — Production deployment patterns beyond cloud-native providers.
- Why your pods are restarting — Health check and liveness probe misconfigurations often mistaken for secret resolution issues.
- DevOps & SRE interview questions — How security engineering patterns and secret management workflows appear within modern technical evaluation pipelines.
- Open DevOps roles on FzlOps.