TL;DR
- Do not commit real
.envfiles. Commit a.env.exampleand.gitignorethe rest. - Local/dev: load from
.envwithdotenv, validate on boot, and keep values per‑developer. - Staging/Prod: use a secrets manager (AWS SSM/Secrets Manager, GCP Secret Manager, Azure Key Vault, Vault, 1Password/Doppler) and inject at runtime.
- Build vs runtime: frontends bake env at build time; secrets must stay server‑side.
- Security basics: least privilege, short TTL tokens, rotate, audit, and never log secrets.
Why you shouldn’t commit .env
- Forever exposure: once pushed, copies live in forks, mirrors, backups. Rotating later doesn’t un‑leak the past.
- Propagation risk: CI logs, caches, crash dumps, and artifact uploads can spread secrets.
- Accidental reuse: dev secrets end up in staging/prod by copy‑paste.
Rule: Commit placeholders only; real values live outside git.
Minimum files in repo
# .gitignore
.env
.env.*
!.env.example
Template file (commit this)
# .env.example
DATABASE_URL=postgres://user:pass@localhost:5432/app
JWT_SECRET=change-me
REDIS_URL=redis://localhost:6379
The model: config via env (12‑Factor)
Treat configuration as environment. Your app reads from process.env (Node) or os.environ (Python), not from hard‑coded files. Keep the same artifact across environments; only the env changes.
Local development (safe & easy)
- Use dotenv only in dev/test.
- Keep secrets per‑developer in
.env(ignored by git). - Validate on startup and fail fast if required keys are missing.
- Optional: use direnv or language‑specific env managers to load on cd.
Node (dotenv + zod)
// Load only outside production
if (process.env.NODE_ENV !== "production") await import("dotenv/config");
import { z } from "zod";
const Env = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(16),
PORT: z.coerce.number().default(3000),
});
export const env = Env.parse(process.env);
Python (pydantic + python-dotenv)
import os
from pydantic import BaseModel, Field, ValidationError
from dotenv import load_dotenv
if os.getenv("ENV") != "production":
load_dotenv()
class Settings(BaseModel):
DATABASE_URL: str
JWT_SECRET: str
PORT: int = Field(default=3000)
try:
settings = Settings(**os.environ)
except ValidationError as e:
raise SystemExit(f"Invalid env: {e}")
Docker Compose (dev only)
services:
api:
build: .
env_file: .env # dev convenience; do not commit
ports: ["3000:3000"]
Compose has a special top‑level
.envfor variable substitution; don’t confuse it with your app’s secret file.
Staging & production (the right way)
1) Use a secrets manager
Pick one: AWS SSM/Secrets Manager, GCP Secret Manager, Azure Key Vault, Vault, or 1Password/Doppler. Source secrets just‑in‑time at deploy or startup.
2) Inject at runtime (don’t bake into images)
- Containers: pass via task definitions, orchestrator env, or mounted secret files with tight perms.
- Kubernetes: use Secrets (enable encryption at rest), or sync from cloud stores via External Secrets Operator / CSI Secret Store. Mount as env vars or tmpfs files.
- Serverless: use platform env/secrets; avoid bundling into code at build.
Kubernetes example
apiVersion: v1
kind: Secret
metadata: { name: app-secrets }
type: Opaque
stringData:
DATABASE_URL: postgres://...
JWT_SECRET: supersecret
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: api
image: myapp:latest
envFrom: [{ secretRef: { name: app-secrets } }]
K8s Secrets are base64‑encoded; turn on encryption at rest, restrict RBAC, and prefer syncing from a managed store.
3) CI/CD without long‑lived keys
Use OIDC‑based federation to get short‑lived credentials in CI (no static cloud keys).
GitHub Actions → AWS (example)
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::<acct>:role/gh-oidc-deploy
aws-region: us-east-1
- name: Read secrets at deploy time
run: |
DB_URL=$(aws ssm get-parameter --name /app/DB_URL --with-decryption --query Parameter.Value --output text)
export DATABASE_URL="$DB_URL"
./scripts/deploy.sh
Build‑time vs runtime (frontends & serverless)
- Many bundlers inline env at build (Vite/Next.js). Changing a secret later doesn’t affect the already built bundle.
- In Next.js, only
NEXT_PUBLIC_*variables are exposed to the browser. Never put secrets in public env. - Prefer a server‑side API to handle secret‑backed operations.
Rotation, audit, & hygiene
- Rotate keys regularly and after any leak. Use short‑lived tokens where possible.
- Scope secrets minimally (least privilege). Separate dev/staging/prod.
- Audit: who accessed what and when. Turn on secret versioning.
- Mask values in logs/CI; disable
set -xaround secret handling. - Scan repos and images for leaked secrets; block pushes on hits.
If you must keep an encrypted file in git
Prefer a manager, but if policy requires checked‑in config, use SOPS (age or GPG) and decrypt only in CI or on approved machines.
sops -e .env > .env.enc
# commit .env.enc, not .env
sops -d .env.enc > .env # in CI step or locally
Common pitfalls & fast fixes
| Problem | Why it’s dangerous | Fix |
|---|---|---|
| Committing .env | Permanent leak | .gitignore, rotate, purge history if possible |
| Baking secrets into Docker layers | Anyone with image can extract | Inject at runtime; rebuild without secrets |
| Logging env on startup | Secrets in logs forever | Log keys’ presence, not values |
| Frontend uses secret env | Secrets shipped to users | Keep secrets server‑side; use public, non‑secret config only |
| Using one secret across all envs | Blast radius | Separate dev/staging/prod secrets & accounts |
| Long‑lived CI cloud keys | Easy to exfiltrate | Use OIDC short‑lived creds |
| Missing validation | Crashes later | Validate env on boot (schema) |
Quick checklists
Local
- [ ]
.envignored;.env.examplecommitted. - [ ]
dotenvloaded only in dev/test. - [ ] Schema validation on boot.
Staging/Prod
- [ ] Secrets from manager, not repo.
- [ ] Runtime injection (not baked).
- [ ] OIDC in CI, no static keys.
- [ ] Rotation, audit, access least privilege.
- [ ] K8s: encryption at rest + RBAC; consider External Secrets.
One‑minute adoption plan
- Add
.gitignore+.env.exampletoday; remove any committed secrets and rotate them. - Implement schema validation for env at app start.
- Wire secrets manager → CI/CD → runtime injection for staging & prod (OIDC if available).
- Purge secrets from Docker images and logs; add scanners to CI.
- Document the process in your README and keep
.env.examplecurrent.