caduh

Securely Managing Environment Variables (The Right Way)

4 min read

Why you shouldn’t commit .env files, how to handle secrets differently in local, staging, and production, and the exact patterns to inject, validate, rotate, and audit configuration safely.

TL;DR

  • Do not commit real .env files. Commit a .env.example and .gitignore the rest.
  • Local/dev: load from .env with dotenv, 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 .env for 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 -x around 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

  • [ ] .env ignored; .env.example committed.
  • [ ] dotenv loaded 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

  1. Add .gitignore + .env.example today; remove any committed secrets and rotate them.
  2. Implement schema validation for env at app start.
  3. Wire secrets manager → CI/CD → runtime injection for staging & prod (OIDC if available).
  4. Purge secrets from Docker images and logs; add scanners to CI.
  5. Document the process in your README and keep .env.example current.