Environment Variables and Secrets: Stop Leaking Your Keys

A practical guide to managing configuration and secrets across local, CI, and production — .env files, the twelve-factor approach, secret managers, and the mistakes that leak keys to GitHub.

Building & Shipping: Environment Variables and Secrets: Stop Leaking Your Keys

The problem in one sentence

Your code needs values that differ between your laptop and production — database URLs, API keys, feature flags — and some of those values must never appear in your git history. Getting this wrong is how startups end up with their AWS keys scraped off GitHub and a five-figure crypto-mining bill.

Configuration vs secrets

They’re related but not the same:

  • Configuration varies by environment but isn’t sensitive: log level, port number, feature flags, the API base URL.
  • Secrets would cause harm if exposed: database passwords, API keys, signing secrets, OAuth client secrets.

Both belong in the environment, not in code. But secrets need extra handling — encryption at rest, restricted access, rotation. Treating a config value as a secret is harmless; treating a secret as plain config is how leaks happen.

The twelve-factor principle

The Twelve-Factor App methodology states config should live in the environment, not the codebase. The test it proposes is sharp: could you open-source your repo right now without leaking any credentials? If not, you have secrets in your code.

// ❌ Secret hardcoded — leaks the moment the repo is shared
const db = connect('postgres://admin:hunter2@prod-db:5432/app');

// ✅ Read from environment
const db = connect(process.env.DATABASE_URL);

Local development: .env files

For local work, a .env file holds your values:

# .env
DATABASE_URL=postgres://localhost:5432/myapp_dev
API_KEY=dev_key_12345
LOG_LEVEL=debug

Load it early, before any code reads process.env:

// Node 20.6+ has built-in support:
// node --env-file=.env server.js

// Or with dotenv for older versions:
import 'dotenv/config';

console.log(process.env.DATABASE_URL);

The single most important line in your project:

# .gitignore
.env
.env.local
.env.*.local

Add this before your first commit. If .env is ever committed, removing it later doesn’t help — it’s in the history forever, and anyone who cloned the repo has it. You’d have to rotate every secret in that file and rewrite history.

The .env.example pattern

An empty .env gives new teammates nothing to go on. Commit a .env.example (this one is safe — it has keys, not values):

# .env.example — committed to git
DATABASE_URL=
API_KEY=
LOG_LEVEL=debug
STRIPE_SECRET_KEY=

New developers copy it and fill in real values:

cp .env.example .env
# then edit .env with real credentials

This documents what configuration exists without leaking what the values are. Update .env.example whenever you add a new variable — a missing entry means a teammate’s app crashes with a cryptic undefined somewhere deep in startup.

Validate at startup, not at 2am

The worst failure mode is a missing env var surfacing as a TypeError: Cannot read property of undefined three layers deep, in production, at night. Validate everything the moment the app boots:

import { z } from 'zod';

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  PORT: z.coerce.number().default(3000),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
});

// Throws immediately if anything is missing or malformed
export const env = envSchema.parse(process.env);

Now a missing DATABASE_URL fails at startup with a message that names the exact variable, instead of a mysterious crash later. z.coerce.number() also handles the fact that all environment variables are stringsprocess.env.PORT is "3000", not 3000, and that string-vs-number bug bites everyone eventually.

The framework prefix gotcha

Client-side frameworks only expose variables with a specific prefix to the browser, on purpose — so you don’t accidentally ship your database password to every visitor:

  • Vite / Astro: only PUBLIC_ (Astro) or VITE_ (Vite) prefixed vars reach client code
  • Next.js: only NEXT_PUBLIC_ prefixed vars
  • Create React App: only REACT_APP_ prefixed vars
# .env
PUBLIC_ANALYTICS_ID=UA-123456     # safe — sent to browser
DATABASE_URL=postgres://...        # stays server-side only

The flip side is the real danger: never put a secret behind a public prefix. PUBLIC_STRIPE_SECRET_KEY would be bundled straight into your JavaScript and visible in every user’s dev tools. Public prefix = published to the world. Use the publishable key client-side, keep the secret key server-side.

CI: secrets in the pipeline

Your CI needs some of these values too. Every CI platform has an encrypted secret store — never put real values in the YAML:

# GitHub Actions
- env:
    DATABASE_URL: ${{ secrets.DATABASE_URL }}
    STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
  run: npm run migrate

CI providers mask secrets in logs — if a secret value would print, it’s replaced with ***. But masking is best-effort: if you base64-encode a secret and print that, the encoded form isn’t masked. Don’t print secrets, encoded or not.

Production: beyond .env files

.env files work for local dev but scale badly in production:

  • They sit in plaintext on disk
  • Rotating a key means redeploying
  • No audit log of who accessed what
  • Easy to accidentally include in a Docker image

Production options, roughly in order of setup effort:

Platform env vars (Vercel, Netlify, Railway, Render): set them in the dashboard, injected at runtime. Fine for most small-to-mid apps. Zero extra infrastructure.

Secret managers (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault): encrypted storage with access policies, audit logs, and automatic rotation. Worth it when you have compliance requirements or many services sharing secrets.

// Example: AWS Secrets Manager
import { SecretsManagerClient, GetSecretValueCommand }
  from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({ region: 'us-east-1' });

async function getSecret(name) {
  const response = await client.send(
    new GetSecretValueCommand({ SecretId: name })
  );
  return JSON.parse(response.SecretString);
}

const { password } = await getSecret('prod/db/credentials');

Don’t reach for Vault on day one. Platform env vars cover most needs; graduate to a secret manager when you have a concrete reason.

Docker: the layer-caching leak

A classic mistake bakes secrets into an image:

# ❌ Secret is now in the image layer, forever
ENV API_KEY=sk_live_abcd1234

Anyone who can pull the image runs docker history and reads it. Instead, pass secrets at runtime:

docker run --env-file .env myapp
# or
docker run -e API_KEY="$API_KEY" myapp

For build-time secrets (like a private npm token), use BuildKit’s --secret, which mounts the secret for a single step without persisting it in a layer:

# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci

What to do if you leak one

It happens. If a secret hits a public repo:

  1. Rotate it immediately. Assume it’s compromised the second it’s public — bots scan GitHub for keys within minutes. Revoking is the only real fix; deleting the commit is not enough.
  2. Then clean history if you want (git filter-repo), but rotation is what actually protects you.
  3. Check for abuse — review access logs and billing for the exposed service.

GitHub’s secret scanning will often email you before the bots find it, and some providers (like Stripe and AWS) auto-revoke keys GitHub reports. Don’t rely on that safety net, but it’s saved plenty of people.

The checklist

  • .env is in .gitignore before the first commit
  • .env.example documents every variable (keys only, no values)
  • Env vars validated at startup with clear error messages
  • No secret sits behind a PUBLIC_/VITE_/NEXT_PUBLIC_ prefix
  • CI secrets live in the encrypted store, not the YAML
  • Production uses platform env vars or a secret manager, not a deployed .env
  • Docker secrets passed at runtime, not baked with ENV
  • A rotation plan exists for when (not if) something leaks

Get the .gitignore line and startup validation right, and you’ve prevented the two most common ways this goes wrong. Everything else is refinement.