The problem with .env files
- They get committed. Someone forgets to
.gitignorethe production.envfile. Now your database password is in Git history forever. - They spread. Developers copy
.envfiles over Slack. Each copy is a potential leak. - No rotation. Changing a secret means updating every server and every developer's local file.
- No audit trail. Who accessed which secret? When? You have no idea.
HashiCorp Vault: the centralized answer
Vault stores secrets centrally, provides access control, generates dynamic credentials, and logs every access.
Setting up the KV secrets engine
# Enable the key-value secrets engine
vault secrets enable -version=2 kv
# Store a secret
vault kv put kv/production/api \
DATABASE_URL="postgresql://prod:s3cret@db.example.com:5432/app" \
STRIPE_KEY="sk_live_abc123" \
JWT_SECRET="super-secret-key"
# Read it
vault kv get kv/production/api
Access policies
Policies control who can read which secrets:
# policies/api-production.hcl
path "kv/data/production/api" {
capabilities = ["read"]
}
path "kv/data/production/api" {
capabilities = ["read", "update"]
# Only CI/CD can update
required_parameters = ["role"]
}
vault policy write api-production policies/api-production.hcl
Fetching secrets at startup
import Vault from 'node-vault';
const vault = Vault({
apiVersion: 'v1',
endpoint: process.env.VAULT_ADDR,
token: process.env.VAULT_TOKEN,
});
async function loadSecrets(): Promise<Record<string, string>> {
const result = await vault.read('kv/data/production/api');
return result.data.data;
}
// At startup
const secrets = await loadSecrets();
const db = new Pool({ connectionString: secrets.DATABASE_URL });
Dynamic database credentials
Vault's killer feature: it can generate short-lived database credentials on the fly:
# Configure the database secrets engine
vault secrets enable database
vault write database/config/mydb \
plugin_name=postgresql-database-plugin \
connection_url="postgresql://{{username}}:{{password}}@db:5432/app" \
allowed_roles="api-readonly" \
username="vault_admin" \
password="vault_admin_pass"
vault write database/roles/api-readonly \
db_name=mydb \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}';" \
default_ttl=1h \
max_ttl=24h
Now every time your app starts, it gets a unique username and password that expires in 1 hour. If credentials are leaked, the blast radius is one hour, not forever.
Simpler alternatives
Vault is powerful but operationally heavy. For smaller teams:
AWS Secrets Manager
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
const client = new SecretsManagerClient({ region: 'ap-south-1' });
async function getSecret(name: string) {
const command = new GetSecretValueCommand({ SecretId: name });
const response = await client.send(command);
return JSON.parse(response.SecretString!);
}
Integrates natively with ECS, Lambda, and other AWS services. Supports automatic rotation.
Doppler
For teams that want secrets management without running infrastructure:
# Install and configure
doppler setup
# Run your app with injected secrets
doppler run -- node dist/main.js
Doppler injects secrets as environment variables. No code changes. Supports environments, access control, and audit logs.
The minimum viable secrets policy
- Never commit secrets to Git. Use
.gitignoreand pre-commit hooks that scan for patterns likesk_live_,password=, etc. - Rotate secrets quarterly. At minimum. Ideally, use dynamic credentials.
- Use different secrets per environment. Staging and production must have different database passwords.
- Audit access. Know who read which secret and when.
- Encrypt at rest. Wherever secrets are stored, they should be encrypted.
