Stop Hardcoding Secrets: Environment Variables Done Right
Move credentials out of your source code with .env hygiene, per-environment config, and a secrets manager — using patterns that work in any language.
What you'll build / learn
By the end of this tutorial you'll have a clean, repeatable pattern for managing secrets: a .env workflow for local development, airtight .gitignore hygiene, a strategy for per-environment configuration, and a path to a real secrets manager (AWS Secrets Manager) for production. The patterns are language-agnostic, with concrete examples in Node.js and Python.
Prerequisites
- A Unix-like shell (macOS, Linux, or WSL2 on Windows). Commands use Bash/Zsh syntax.
- Git 2.30+ (
git --version). - One runtime to follow the examples:
- Node.js 20.6+ (for the built-in
--env-fileflag), or - Python 3.9+ with
pip.
- Node.js 20.6+ (for the built-in
- For the secrets-manager section: an AWS account and the AWS CLI v2 installed and configured (
aws --version,aws configure). The principles transfer to HashiCorp Vault, Google Secret Manager, Azure Key Vault, or Doppler. - Basic comfort with the terminal and a project under Git.
Why this matters: secrets committed to Git live forever in history. GitHub's own data shows millions of leaked credentials are detected every year. The fix is process, not heroics.
Step 1: Understand the layers
Think in three tiers, from least to most secure:
| Layer | Use case | Secret lives in |
|---|---|---|
.env file |
Local dev only | A gitignored file on your machine |
| Platform env vars | CI/CD, PaaS (Heroku, Vercel, Fly) | The platform's encrypted config store |
| Secrets manager | Production, rotation, audit | AWS/Vault/GCP, fetched at runtime |
The golden rule: secrets enter your app through environment variables at runtime; they are never written to source code or committed to Git.
Step 2: Create a .env file and a committed template
In your project root, create .env for real local values:
cat > .env <<'EOF'
DATABASE_URL=postgres://localhost:5432/myapp_dev
STRIPE_SECRET_KEY=sk_test_replace_me
JWT_SIGNING_SECRET=dev-only-not-for-prod
EOF
Now create a committed template so teammates know which variables exist — without exposing values:
cat > .env.example <<'EOF'
DATABASE_URL=
STRIPE_SECRET_KEY=
JWT_SIGNING_SECRET=
EOF
.env.example is checked in; .env is not. When someone clones the repo they run cp .env.example .env and fill in their own values.
Step 3: Lock down .gitignore
Add these lines to .gitignore:
# Secrets — never commit
.env
.env.*
!.env.example
The !.env.example negation re-includes the template while ignoring .env, .env.local, .env.production, etc.
Verify Git is honoring the rule:
git check-ignore -v .env
Expected output (the path means it's correctly ignored):
.gitignore:2:.env .env
If .env was already committed before you added the rule, Git keeps tracking it. Remove it from the index (this keeps the file on disk):
git rm --cached .env
git commit -m "Stop tracking .env"
If a secret was ever pushed, removing it from the current commit is not enough — it remains in history. Rotate the secret immediately, then scrub history with
git filter-repoor the BFG Repo-Cleaner. Treat any leaked credential as compromised.
Step 4: Load the .env in your app
Node.js (20.6+, no dependencies)
Modern Node loads env files natively:
node --env-file=.env app.js
// app.js
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
throw new Error('DATABASE_URL is not set');
}
console.log('Connecting to:', dbUrl);
On Node 22.7+ you can also use --env-file-if-exists=.env to avoid errors when the file is absent. For older Node versions, use the dotenv package instead:
npm install dotenv
require('dotenv').config();
console.log(process.env.DATABASE_URL);
Python (3.9+)
Use python-dotenv:
pip install python-dotenv
# app.py
import os
from dotenv import load_dotenv
load_dotenv() # reads .env in the current/parent directory
db_url = os.environ.get("DATABASE_URL")
if not db_url:
raise RuntimeError("DATABASE_URL is not set")
print("Connecting to:", db_url)
Key principle: read config from process.env / os.environ, never from the .env file directly. In production there is no .env file — the variables come from the platform or secrets manager, and your code doesn't care which.
Step 5: Per-environment configuration
Don't sprinkle if (env === 'production') throughout your code. Instead, select which variables get loaded by environment. A clean convention:
| File | Committed? | Loaded when |
|---|---|---|
.env |
No | Local default |
.env.local |
No | Local overrides (highest local priority) |
.env.example |
Yes | Documentation only |
In CI and production you don't ship .env files at all. Instead, set real environment variables in the platform:
# Example: GitHub Actions (set in repo Settings > Secrets and variables > Actions)
# Reference them in a workflow:
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run with secret
env:
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
run: node app.js
GitHub injects the secret as an environment variable only for that step, and masks it in logs. Same idea on Vercel (vercel env), Fly.io (fly secrets set), or Heroku (heroku config:set).
Step 6: Graduate to a secrets manager
For production, a secrets manager gives you encryption at rest, fine-grained IAM access, audit logs, and rotation. Here's the pattern with AWS Secrets Manager.
Create a secret:
aws secretsmanager create-secret \
--name myapp/prod/stripe \
--secret-string '{"STRIPE_SECRET_KEY":"sk_live_xxx"}'
Fetch it at runtime instead of baking it into an image. In Node.js:
npm install @aws-sdk/client-secrets-manager
import {
SecretsManagerClient,
GetSecretValueCommand,
} from '@aws-sdk/client-secrets-manager';
const client = new SecretsManagerClient({ region: 'us-east-1' });
export async function loadSecrets() {
const res = await client.send(
new GetSecretValueCommand({ SecretId: 'myapp/prod/stripe' })
);
const parsed = JSON.parse(res.SecretString);
// Promote into process.env so the rest of the app stays env-var based
Object.assign(process.env, parsed);
}
Call await loadSecrets() once during startup, before you read the values. The AWS SDK authenticates via the host's IAM role (EC2 instance profile, ECS task role, or Lambda execution role) — no static AWS keys in your app.
Grant least-privilege access via an IAM policy attached to that role:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:myapp/prod/*"
}
]
}
The wildcard scopes access to your app's secrets only. The same fetch-at-startup pattern works with Vault (vault kv get), Google Secret Manager, or Doppler — only the client library changes.
Verify it works
.envis ignored:git status --shortshows no.env;git check-ignore .envprints.env.- App reads env vars: Run
node --env-file=.env app.js(orpython app.py) and confirm it prints your value. - Missing var fails loudly: Temporarily rename
.envand rerun — you should get the explicitDATABASE_URL is not seterror, proving you validate config at startup. - No secrets in history:
git log -p -- .envreturns nothing (or only its removal commit).
Troubleshooting
.env still shows up in git status after editing .gitignore.
It was already tracked. Run git rm --cached .env, commit, and confirm with git check-ignore -v .env.
Node throws --env-file is not allowed.
You're on Node < 20.6. Either upgrade (nvm install 20) or use the dotenv package as shown in Step 4.
Environment variable is undefined at runtime.
Three common causes: (1) the .env file isn't in the working directory — load_dotenv() and --env-file look relative to where you invoke the process; (2) you read the value before load_dotenv() ran; (3) a typo or stray space around = in the file. Print process.env/os.environ to confirm what actually loaded.
AWS SDK throws AccessDeniedException on GetSecretValue.
The IAM role lacks permission or the ARN/region is wrong. Confirm the attached policy matches the secret's ARN and that the SDK region equals where the secret was created.
Next steps
- Add schema validation for config at boot — Zod (TS) or Pydantic Settings (Python) — so missing or malformed env vars crash on startup, not mid-request.
- Enable automatic rotation in AWS Secrets Manager (or Vault dynamic secrets) so credentials are short-lived.
- Add a pre-commit hook with Gitleaks or TruffleHog to block secret commits before they happen.
- Read the Twelve-Factor App config chapter — the foundation for everything above.
Discussion 0
Join the discussion
Sign in with GitHub to comment and vote.
No comments yet
Be the first to weigh in.