caduh

When to Use an ENV File (and How to Use it Securely)

4 min read

A practical guide to environment variables—when .env files make sense, how to load them locally, and how to keep secrets out of your repo and images.

TL;DR

  • Use .env files only for local dev and CI variables, not as a long‑term secret store.
  • Never commit secrets; commit .env.example and .gitignore the real files.
  • In production, prefer managed secret stores (Vault, AWS SSM/Secrets Manager, GCP Secret Manager, 1Password, Doppler). Inject at runtime.
  • Validate env at boot with a schema (zod, envalid, dotenv‑safe) and fail fast when required vars are missing.
  • Beware build‑time vs runtime env (frontends often inline at build). Keep secrets server‑side only.

When should you use a .env file?

  • Local development: keep per‑developer settings (DATABASE_URL=...) without touching global shell profiles.
  • Ephemeral CI jobs: as a step input (from a secrets manager) to run tests/builds.
  • Containers/Compose: pass variables into containers for dev.

When you shouldn’t: storing long‑lived production secrets in the repo, baking secrets into Docker images, or distributing .env over chat/email.


Minimum viable setup (safe defaults)

project/
  .env                # local only (never commit)
  .env.example        # template (safe to commit)
  .gitignore
  src/

.gitignore

.env
.env.*
!.env.example

.env.example

# Database
DATABASE_URL=postgres://user:pass@localhost:5432/app

# Auth
JWT_SECRET=change-me
SESSION_TTL_SECONDS=3600

# External APIs
STRIPE_KEY=sk_test_xxx

Share .env.example, not real values. Developers copy it to .env.


Loading env in code (dev only)

Node.js (dotenv)

// load only in dev/test
if (process.env.NODE_ENV !== "production") {
  await import("dotenv/config"); // or: require("dotenv").config();
}

import { cleanEnv, str, num } from "envalid";
export const env = cleanEnv(process.env, {
  DATABASE_URL: str(),
  JWT_SECRET: str(),
  SESSION_TTL_SECONDS: num({ default: 3600 }),
});

Python

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
    SESSION_TTL_SECONDS: int = Field(default=3600)

try:
    settings = Settings(**os.environ)
except ValidationError as e:
    raise SystemExit(f"Invalid env: {e}")

In production, skip loading .env—inject variables from the environment/process manager.


Build‑time vs runtime (frontends & serverless gotcha)

  • Many build systems inline env at build (Next.js, Vite). Changing a secret post‑build doesn’t affect the deployed bundle.
  • In Next.js, only variables prefixed with NEXT_PUBLIC_ are exposed to the browser. Do not put secrets there.
  • Prefer server‐side APIs to handle secrets and keep the frontend public‑only configuration minimal.
  • For serverless platforms, use platform runtime env or secrets—avoid hardcoding in build steps.

Docker & Compose

  • Docker images are public artifacts in many registries—never bake secrets into layers.
  • Pass env at run time:
docker run -e DATABASE_URL=postgres://... -e JWT_SECRET=... myapp:latest

docker-compose.yml

services:
  api:
    image: myapp:latest
    env_file: .env   # loads key=value into container env (dev only)
    # or explicit:
    environment:
      - DATABASE_URL=${DATABASE_URL}
      - JWT_SECRET=${JWT_SECRET}

Compose has a special top‑level .env for variable substitution—don’t confuse it with your app’s .env. Keep secrets in a dedicated file that is .gitignored.

Kubernetes (prod)

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, not encrypted by default—enable encryption at rest, restrict RBAC, and consider Sealed Secrets or an external vault.


Production: use a secrets manager

  • AWS: Systems Manager Parameter Store (SSM) or Secrets Manager (+ IAM/OIDC).
  • GCP: Secret Manager (+ Workload Identity).
  • Azure: Key Vault.
  • Vault: HashiCorp Vault with dynamic DB credentials and leasing.
  • 1Password/Doppler: developer‑friendly sync and rotation.

Pattern: CI pulls secrets → injects as env at runtime (or the platform reads them at deploy). Do not print secrets in logs.


Encrypting .env if you must keep it in git

Use sops (age or GPG) to commit an encrypted .env that decrypts only in CI or on approved machines.

# create an age key
age-keygen -o key.txt
export SOPS_AGE_KEY_FILE=key.txt

# encrypt
sops -e .env > .env.enc
git add .env.enc   # commit the encrypted file only

# decrypt (CI step or locally)
sops -d .env.enc > .env

Still prefer a secrets manager; encrypted files add operational overhead and key distribution challenges.


Ops & rotation

  • Rotate credentials regularly; use short‑lived tokens where possible (OIDC, STS).
  • Scope keys minimally (least privilege) and use separate dev/staging/prod secrets.
  • Audit: who accessed what, when.
  • Mask secrets in CI logs; scrub crash dumps and metrics.
  • Fail closed: app should refuse to start with missing/invalid env.

Common pitfalls & fixes

| Problem | Why it hurts | Fix | |---|---|---| | .env committed to repo | Secret leakage forever | Add to .gitignore, rotate keys, purge history if needed | | Secrets baked into Docker layers | Anyone with image can extract | Pass env at runtime; rebuild without secrets | | Using .env in production | Drift, leakage, manual copies | Use secret store + runtime injection | | Frontend exposes secrets | Bundlers inline at build | Keep secrets server‑side; use public config only | | Missing validation | Crashes at runtime | Use schema validation at boot | | Sharing .env over chat/email | Persistence & searchability risk | Use secure secret sharing or a vault |


Quick checklist

  • [ ] Commit .env.example; ignore real .env.
  • [ ] Load .env only in dev/CI; in prod, inject env via platform.
  • [ ] Validate env at startup; fail fast.
  • [ ] Keep secrets out of images, out of logs.
  • [ ] Prefer a managed secrets store; enable rotation & audit.
  • [ ] Watch build‑time vs runtime env in frontends/serverless.

One‑minute adoption plan

  1. Add .gitignore + .env.example today.
  2. Add schema validation for env on app start.
  3. Move production secrets to a secrets manager and wire CI/CD to inject at deploy.
  4. Rotate any previously committed secrets.
  5. Document env keys in your README and keep .env.example up to date.