Tutorial
Secrets Management for a Dockerised Stack: AWS Secrets Manager, SSM, and Local .env Parity
Production-ready pattern: AWS Secrets Manager and SSM Parameter Store for deployed environments, with genuine local .env parity—not aspirational.
Managing secrets across local development, CI, and production is one of those problems that feels solved until you’re debugging a 3am incident caused by a stale environment variable. Getting secrets management right in a Dockerised stack is not glamorous work, but it is load-bearing work.
This tutorial walks through a production-ready pattern: AWS Secrets Manager and SSM Parameter Store for deployed environments, with a local .env workflow that stays genuinely in parity — not aspirationally in parity.
Why “Just Use .env” Breaks at Scale
A .env file works on your laptop. Problems emerge with multiple services, multiple developers, a CI pipeline, and multiple deployment environments. You end up with .env.staging, .env.production, secrets scattered across machines, and rotation procedures nobody follows because they’re too manual.
Two AWS services address this:
- AWS Secrets Manager — for secrets requiring rotation (database passwords, API keys, OAuth credentials). The per-secret monthly cost justifies itself through automation.
- SSM Parameter Store — for configuration values and non-sensitive parameters. The
SecureStringtype encrypts values with KMS, making it viable for secrets, and the standard tier is free.
Use Secrets Manager for anything that rotates or is genuinely sensitive at rest. Use SSM for configuration that varies by environment but isn’t catastrophic if exposed.
The Core Pattern: Fetch at Container Startup
The most common mistake: baking secrets into Docker images as build-time arguments. Don’t. Instead, fetch secrets at container startup via an entrypoint script.
Here’s a minimal entrypoint for a Python service (Django or FastAPI):
#!/bin/sh
set -e
# Fetch secrets from AWS Secrets Manager and export as env vars
SECRET_JSON=$(aws secretsmanager get-secret-value \
--secret-id "$SECRET_NAME" \
--query SecretString \
--output text)
export DB_PASSWORD=$(echo "$SECRET_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['db_password'])")
export API_KEY=$(echo "$SECRET_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['api_key'])")
exec "$@"
Your Dockerfile calls this before the main process:
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["gunicorn", "myapp.wsgi"]
The container IAM role grants secretsmanager:GetSecretValue on the specific secret ARN only.
SSM for Per-Environment Configuration
For non-secret, environment-specific values (feature flags, service URLs, log levels), SSM Parameter Store with a naming convention is cleaner:
/myapp/production/LOG_LEVEL
/myapp/staging/LOG_LEVEL
/myapp/production/FEATURE_FLAG_NEW_CHECKOUT
Fetch a whole prefix at once:
aws ssm get-parameters-by-path \
--path "/myapp/$APP_ENV/" \
--with-decryption \
--query "Parameters[*].[Name,Value]" \
--output text | while read name value; do
key=$(basename "$name")
export "$key=$value"
done
Add this to the entrypoint script before your exec call. Configuration and secrets are both resolved at runtime, never baked in.
Achieving Local .env Parity
Most tutorials stop here. They explain AWS and leave your .env drifting from production within a week.
The fix: a script that generates your local .env by pulling from the same SSM path and Secrets Manager secret your containers use:
#!/usr/bin/env bash
# scripts/generate-local-env.sh
set -e
APP_ENV=${1:-development}
echo "# Auto-generated — do not commit" > .env
# Pull SSM config
aws ssm get-parameters-by-path \
--path "/myapp/$APP_ENV/" \
--with-decryption \
--query "Parameters[*].[Name,Value]" \
--output text | while read name value; do
key=$(basename "$name")
echo "$key=$value" >> .env
done
# Pull Secrets Manager values
SECRET_JSON=$(aws secretsmanager get-secret-value \
--secret-id "myapp/$APP_ENV/app-secrets" \
--query SecretString \
--output text)
echo "$SECRET_JSON" | python3 -c "
import sys, json
for k, v in json.load(sys.stdin).items():
print(f'{k.upper()}={v}')
" >> .env
echo ".env generated for $APP_ENV"
Add .env to .gitignore. Commit scripts/generate-local-env.sh to the repo. Every developer pulls from the same source of truth as production. Rotating a secret requires running one script, not finding four .env files.
Docker Compose Wiring
For local development, load the generated .env:
services:
api:
build: .
env_file:
- .env
For deployed environments, the entrypoint script handles everything. Never use env_file in production compose or ECS task definitions.
What This Solves
This pattern gives you a single source of truth (AWS), runtime injection identical across ECS, Kubernetes, or bare Docker, and a local workflow that stays in sync. Secret rotation becomes an AWS operation; containers pick up new values on next restart without image rebuilds.
It requires upfront effort, but it pays back the first time you rotate a compromised key at speed.