Secrets Management for Developers: .env, Vaults, and Best Practices
Secrets Management for Developers: .env, Vaults, and Best Practices
Every application has secrets — API keys, database passwords, signing keys, OAuth tokens. How you manage them determines whether they end up in a git commit, a Slack message, or a breach notification. Most developers know not to hardcode secrets, but the gap between "don't commit secrets" and "actually manage secrets well" is wider than expected.
.env Files (Development)
The .env pattern is the standard for local development. Store secrets in a .env file, load them as environment variables, and gitignore the file.
# .env (gitignored)
DATABASE_URL=postgresql://dev:dev@localhost:5432/myapp
REDIS_URL=redis://localhost:6379
API_KEY=sk_test_abc123
JWT_SECRET=local-dev-secret-not-for-production
STRIPE_SECRET_KEY=sk_test_456
# .env.example (committed — template without real values)
DATABASE_URL=postgresql://user:pass@localhost:5432/dbname
REDIS_URL=redis://localhost:6379
API_KEY=your-api-key-here
JWT_SECRET=generate-a-random-string
STRIPE_SECRET_KEY=sk_test_your-key
Loading .env Files
Node.js/Bun:
Bun loads .env automatically. Node.js 20.6+ supports --env-file:
# Bun — automatic
bun run app.ts
# Node.js
node --env-file=.env app.js
For older Node.js versions:
import 'dotenv/config';
// process.env.DATABASE_URL is now available
Python:
from dotenv import load_dotenv
import os
load_dotenv()
db_url = os.environ["DATABASE_URL"]
.gitignore
# .gitignore
.env
.env.local
.env.*.local
!.env.example
Always commit a .env.example with placeholder values so new developers know what variables to set.
Secret Scanning
git-secrets
Prevent committing secrets with a pre-commit hook:
brew install git-secrets
# Install hooks in your repo
cd my-project && git secrets --install
# Add patterns to detect
git secrets --register-aws # Catches AWS keys
# Add custom patterns
git secrets --add 'sk_live_[a-zA-Z0-9]{24}' # Stripe live keys
git secrets --add 'ghp_[a-zA-Z0-9]{36}' # GitHub personal tokens
gitleaks
gitleaks scans your entire git history for leaked secrets:
brew install gitleaks
# Scan current state
gitleaks detect
# Scan entire git history
gitleaks detect --log-opts="--all"
# Use in CI (GitHub Actions)
# .github/workflows/security.yml
- name: Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GitHub Secret Scanning
GitHub automatically scans public repositories for known secret patterns (AWS keys, Stripe keys, Twilio tokens, etc.) and notifies you. For private repos, enable it in repository settings → Security → Secret scanning.
Secrets in CI/CD
GitHub Actions Secrets
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
run: ./deploy.sh
GitHub masks secret values in logs automatically. Never echo secrets — echo $API_KEY shows *** in logs, but echo ${API_KEY:0:5} would leak the first 5 characters.
Limitations:
- 1,000 organization secrets limit
- Secrets can't be read by forked PRs (security feature)
- No versioning — overwriting a secret deletes the old value
Environment-Specific Secrets
GitHub supports deployment environments with their own secrets:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production # Uses production secrets
steps:
- env:
DATABASE_URL: ${{ secrets.DATABASE_URL }} # Production URL
run: ./deploy.sh
Production Secret Stores
1Password CLI (op)
1Password works as a secrets manager, not just a password manager. The CLI retrieves secrets at runtime:
brew install 1password-cli
# Sign in
op signin
# Read a secret
op read "op://Vault/Database/password"
# Inject secrets into a command
op run --env-file=.env.tpl -- bun run start
# .env.tpl (template with 1Password references)
DATABASE_URL=postgresql://app:{{ op://Production/Database/password }}@db.example.com:5432/myapp
API_KEY={{ op://Production/Stripe/api-key }}
op run replaces the references with actual values and passes them as environment variables to the command. The secrets never touch disk.
Doppler
Doppler is a dedicated secrets management platform. It syncs secrets across development, staging, and production.
brew install dopplerhq/cli/doppler
# Setup
doppler setup # Interactive — select project and environment
# Run with injected secrets
doppler run -- bun run start
# Fetch a specific secret
doppler secrets get DATABASE_URL --plain
Doppler's strength is managing secrets across environments. You define secrets once and configure them per environment (dev, staging, production). It integrates with GitHub Actions, Vercel, AWS, and most deployment platforms.
Pricing: Free for 5 users and 3 projects. Paid starts at $4/user/month.
SOPS (Secrets OPerationS)
SOPS encrypts secrets files so they can be committed to git. Only specific keys (AWS KMS, GCP KMS, age, PGP) can decrypt them.
brew install sops age
# Generate an age key
age-keygen -o key.txt
# Public key: age1abc...
# Create a SOPS config
cat > .sops.yaml << 'EOF'
creation_rules:
- path_regex: secrets/.*\.yaml
age: age1abc...
EOF
# Encrypt a file
sops secrets/production.yaml
# Opens your editor — save and the file is encrypted
# secrets/production.yaml (encrypted — safe to commit)
database_url: ENC[AES256_GCM,data:abc123...,tag:def456...]
api_key: ENC[AES256_GCM,data:ghi789...,tag:jkl012...]
sops:
age:
- recipient: age1abc...
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
...
# Decrypt and use
sops -d secrets/production.yaml
# Or pipe to a command
sops -d secrets/production.yaml | yq '.database_url'
SOPS is good for small teams that want secrets in git (versioned, auditable) without a separate secret store.
Best Practices
Never commit secrets. Use
.env.examplefor templates, gitignore actual.envfiles, and run gitleaks in CI.Use different secrets per environment. Dev, staging, and production should have separate database passwords, API keys, and signing keys.
Rotate secrets regularly. When someone leaves the team, rotate every secret they had access to.
Prefer short-lived tokens. OAuth tokens that expire in an hour are safer than API keys that last forever.
Audit access. Know who has access to production secrets. Use tools that log access (1Password, Doppler, AWS Secrets Manager).
Don't pass secrets as command-line arguments. They show up in
psoutput. Use environment variables or files.
# Bad — visible in process list
./app --db-password=secret123
# Good — environment variable
DATABASE_URL=postgresql://... ./app
# Good — file reference
./app --config /run/secrets/db-password
- Least privilege. Give each service only the secrets it needs. Your frontend doesn't need the database password.