UseToolSuite UseToolSuite

Environment Variables and Config Management: A Developer's Guide

A practical guide to environment variables: the 12-Factor App approach, Zod schema validation, Docker ARG vs ENV security, and managing production secrets.

Necmeddin Cunedioglu Necmeddin Cunedioglu 7 min read
Part of the Developer Generators: The Tools That Save You Hours Every Week series

Practice what you learn

Base64 Encoder / Decoder

Try it free →

Environment Variables and Config Management: A Developer’s Guide

Here’s a disaster that plays out regularly: a developer runs git add . late on a Friday and pushes a local .env file to a public GitHub repo. That file holds the production database URL, an AWS access key, and a live Stripe secret key.

Within hours, bots scanning GitHub harvest the plaintext keys and spin up large EC2 instances across several regions to mine cryptocurrency. The AWS bill hits five figures before anyone notices, and the team spends the weekend rotating passwords, invalidating user sessions, and writing a post-mortem on how one text file caused so much damage.

Config management — and secrets management specifically — looks trivial during local development right up until you get it wrong in production. This guide covers the 12-Factor App approach, .env file hierarchies, Docker layer traps (ARG vs ENV), JavaScript type coercion bugs, and Zod schema validation.

Need to encode a secret string? To store binary data, multi-line RSA certificates, or JSON inside a single variable, Base64 is the standard approach. Use our local Base64 Encoder to encode values before saving them to config files.

1. The 12-Factor App Approach

The 12-Factor App methodology is a well-known set of practices for building scalable SaaS apps. Factor III is about configuration.

It sets one rule: store configuration in the environment, never as constants in the codebase.

“Configuration” means anything that varies between environments (local, staging, production):

  • Database connection strings (DATABASE_URL)
  • API keys and secrets (STRIPE_SECRET_KEY)
  • Feature flags (ENABLE_NEW_PAYMENT_DASHBOARD)
  • Internal service URLs (REDIS_URL, ELASTICSEARCH_URL)
  • Port numbers (PORT)

The litmus test

How do you know your config management is secure? Ask: “Could I make my entire codebase public on GitHub right now without compromising any credentials or user data?”

If the answer is no, you’re failing Factor III.

// ❌ Hardcoded configuration
// If this file is ever committed, the production database is compromised.
const db = new Database('postgres://admin:SuperSecretPass123!@prod-db.internal:5432/myapp');

// ✅ Environment variable
// The codebase contains no secrets. The environment injects them at runtime.
const db = new Database(process.env.DATABASE_URL);

2. The .env File Hierarchy

The host OS injects environment variables into a running Node.js or Python process. In production this is handled by Docker, Kubernetes, or a PaaS provider (Vercel, AWS ECS). But running export DATABASE_URL=... in your terminal every time you open a new tab is tedious.

That’s what .env files are for. They’re local, uncommitted text files that libraries like dotenv (Node.js) or python-dotenv parse to inject variables into process.env at startup.

Standard .env structure

# .env
DATABASE_URL=postgres://localhost:5432/myapp_dev
API_KEY=sk_test_abc123
REDIS_URL=redis://localhost:6379
DEBUG=true
PORT=3000

The cascading hierarchy

Frameworks like Next.js, Vite, and NestJS support a cascade of .env files. Variables in higher-priority files override those in lower-priority ones.

  1. .env.local (highest priority): your personal machine. Holds your overrides and private test keys. Always gitignore it.
  2. .env.development: shared config for local development.
  3. .env.production: config used when building the production bundle.
  4. .env (lowest fallback): shared defaults across the team. Holds non-sensitive values like PORT=3000 or LOG_LEVEL=info. Safe to commit only if you’ve confirmed it contains no secrets.

3. Git Security: Preventing Secret Leaks

The .gitignore rule

The most important file in your repo is .gitignore. Before writing any business logic, make sure your environment-file rules are in place.

# .gitignore

# Ignore local environment files
.env
.env.local
.env.*.local

# Ignore production secrets
.env.production
.env.staging

Secret scanning in CI/CD

Relying on people remembering to update .gitignore isn’t enough. Add automated secret scanning to your GitHub Actions pipeline.

Tools like TruffleHog, gitleaks, or GitHub Advanced Security scan each commit for high-entropy strings that look like API keys, RSA tokens, or AWS secrets. If one is detected, the build fails and the code can’t merge to main.

# GitHub Actions workflow using Gitleaks
name: Secret Scanner
on: [push, pull_request]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 0 # Required: scan all history, not just the latest commit
      - name: Run Gitleaks
        uses: zricethezav/gitleaks-action@v2

Incident response for a leaked secret

If a key is pushed to a public repo, deleting the file and pushing a new commit doesn’t fix it. The secret lives forever in the commit history.

Run this protocol immediately:

  1. Revoke the key: log into the AWS/Stripe/SendGrid dashboard and revoke the exposed key. Assume it’s already compromised.
  2. Rotate credentials: generate a new key and share it with the team through a secure channel (1Password, AWS Parameter Store).
  3. Audit access logs: check the provider’s logs to see if, when, and how the key was used.
  4. Scrub Git history: use BFG Repo-Cleaner or git filter-repo to rewrite history and remove the file from all past commits.

4. Schema Validation with Zod

A common source of hard-to-debug production bugs is missing or malformed environment variables. If your app needs a 32-byte JWT_SECRET to sign tokens and that variable is missing in production, the app may start fine and then crash the moment a user logs in.

Best practice: validate config at startup (fail fast).

The Zod pattern (TypeScript / Node.js)

Instead of checking process.env randomly throughout the codebase, use a schema library like Zod to parse, type-cast, and validate the whole environment before the server binds to a port.

import { z } from 'zod';

// 1. Define the shape and constraints of your environment
const envSchema = z.object({
  DATABASE_URL: z.string().url(), // Must be a valid URI
  JWT_SECRET: z.string().min(32), // At least 32 characters for entropy
  PORT: z.coerce.number().default(3000), // Coerce the string to a Number
  NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'),
  DEBUG: z.coerce.boolean().default(false), // Coerce "true"/"false" to a Boolean
});

// 2. Parse the environment. If validation fails, Zod throws a descriptive error and the app crashes immediately.
export const env = envSchema.parse(process.env);

// 3. Use the validated `env` object instead of `process.env`.
// env.PORT is guaranteed by TypeScript to be a Number, not a String.
console.log(`Server starting on port ${env.PORT}`);

5. The Type Coercion Trap

If you don’t use a schema library like Zod, remember one thing: environment variables are always strings.

The OS doesn’t pass JavaScript booleans or numbers as env vars.

// ❌ Bug 1: integer addition failure
// If PORT="3000", this concatenates to the string "30001", not the number 3001.
const nextPort = process.env.PORT + 1; 

// ✅ Fix: explicit integer parsing
const nextPort = parseInt(process.env.PORT, 10) + 1;


// ❌ Bug 2: boolean truthiness failure
// If DEBUG="false", the string "false" is truthy in JavaScript!
if (process.env.DEBUG) {
  enableVerboseDebugMode(); // This always runs
}

// ✅ Fix: explicit string comparison
const isDebugActive = process.env.DEBUG === 'true';
if (isDebugActive) {
  enableVerboseDebugMode();
}

6. Docker Security: ARG vs ENV

When containerizing apps, it’s easy to leak secrets into the Docker image layers by misunderstanding the difference between ARG and ENV.

The ENV vulnerability

# ❌ Baking secrets into the image layer
FROM node:18-alpine
ENV API_KEY="sk_live_very_secret_key"
COPY . /app

Push this image to a public DockerHub or ECR registry and anyone who pulls it can run docker history image_name or inspect the layers to extract the plaintext key.

The secure approach: runtime injection

Pass secrets to the container at runtime, never during docker build.

# ✅ The Dockerfile contains no secrets
FROM node:18-alpine
COPY . /app
CMD ["npm", "start"]

Then inject the variables from the host when starting the container:

# Using the CLI
docker run -e API_KEY="sk_live_secret" -p 3000:3000 myapp

# Using a gitignored env-file
docker run --env-file ./production.env -p 3000:3000 myapp

7. Next.js Build-Time vs Runtime Variables

Frameworks like Next.js add a config layer: some variables get bundled into the JavaScript sent to the browser.

In Next.js, any variable prefixed with NEXT_PUBLIC_ is inlined into the client-side bundle during the build.

# .env
DATABASE_PASSWORD=super_secret_db_pass  # Secure: server-only
NEXT_PUBLIC_STRIPE_KEY=pk_test_123      # Public: injected into the browser bundle

Warning: never prefix a secret key or database password with NEXT_PUBLIC_. If you do, the secret ships to the browser, and anyone can extract it from DevTools.

8. Production Secrets Management

In production, local .env files are replaced by dedicated, encrypted platforms that inject variables into the app’s memory at boot.

CloudSecret Manager
AWSSecrets Manager (encrypted via KMS), SSM Parameter Store
Google CloudSecret Manager
AzureKey Vault
KubernetesK8s Secrets (enable encryption-at-rest on etcd)
HashiCorpVault

Further Reading


Secure your config workflow. Generate strong secret keys with our local Password Generator, encode multi-line payloads with the Base64 Encoder, and convert infrastructure files locally with the YAML to JSON Converter.

Necmeddin Cunedioglu
Necmeddin Cunedioglu Author
7 min read
-- views

Software developer and the creator of UseToolSuite. I write about the tools and techniques I use daily as a developer — practical guides based on real experience, not theory.