Environment Variables & Config

Keeping secrets and settings that differ between environments out of your code, so the same code can run safely in development, testing, and production.

What is it?

A backend needs to know things like which database to connect to, what API key to use for sending emails, and whether it's running in development or production. It's tempting to just write those values directly into the code — but that causes real problems: a secret key written into a file gets committed to version control for anyone with repo access to see, and a database address hardcoded for your laptop breaks the moment the same code runs on a production server.

Environment variables are values set outside the code, in the environment the program runs in, and read by the program at startup. Instead of writing the actual value in your source file, you write code that asks "whatever the environment says this value is" — and each environment (your laptop, a staging server, production) can supply a different answer.

Explain like I'm 10

Think of a recipe that says 'add the number of servings you need' instead of hardcoding 'serves 4.' The same recipe card works whether you're cooking for 2 people or 20 — you just supply a different number depending on the situation, without editing the recipe itself.

Examples

Reading configuration from the environment

// .env file (never committed to version control)
DATABASE_URL=postgres://localhost:5432/myapp_dev
STRIPE_SECRET_KEY=sk_test_abc123
PORT=3000

// app.js
require("dotenv").config();

const port = process.env.PORT || 3000;
const dbUrl = process.env.DATABASE_URL;

app.listen(port, () => console.log(`Listening on ${port}`));

The actual secret values live in a .env file that's excluded from version control, while the code just refers to process.env.DATABASE_URL by name.

Switching behavior per environment

const isProduction = process.env.NODE_ENV === "production";

if (isProduction) {
  app.use(compressionMiddleware());
  logger.level = "warn";
} else {
  logger.level = "debug";
}

A single environment variable, NODE_ENV, lets the exact same code file behave differently depending on where it's deployed.

How it works

Environment variables are set at the operating system or process level — outside of your source code entirely. In production, a hosting platform typically lets you set them through a dashboard or config file that's separate from your codebase. In local development, a .env file combined with a small library (like dotenv) simulates that same mechanism by loading values into process.env when the app starts. Your code then reads from process.env rather than containing the literal values.

Why does it exist?

Secrets committed to source control stay in that repository's history forever, even if you delete them later — a serious security risk if the repo is ever exposed. Separately, the same code genuinely needs different settings in different places (a local database versus a production one). Environment variables solve both problems at once: secrets stay out of the codebase, and configuration becomes swappable per environment without touching a single line of code.

When to use it

Use environment variables for anything that's either sensitive (API keys, database credentials, tokens) or that legitimately differs between environments (a port number, a feature flag, which environment the app thinks it's running in).

When not to use it

Values that never change and aren't sensitive — like the name of a constant used only inside a single calculation — don't need to be environment variables; that just adds indirection for no benefit. Keep genuine constants as regular code.

Common mistakes

  • Committing a real .env file (with actual secrets) to version control instead of adding it to .gitignore.

  • Forgetting to set a required environment variable in production, causing the app to crash or silently misbehave.

  • Hardcoding a fallback secret value directly in code 'just for now' and forgetting to remove it before shipping.

Practice exercises

  1. Easy:

    Move a hardcoded database URL out of a source file and into a .env file, reading it via process.env.

  2. Medium:

    Write code that uses NODE_ENV to log at 'debug' level in development and 'error' level in production.

  3. Hard:

    Explain what could go wrong if a required environment variable is missing in production, and how you'd make the app fail loudly instead of silently at startup.

Interview questions

What are environment variables and why are they used?

Configuration values supplied by the environment a program runs in rather than hardcoded in source — used to keep secrets out of the codebase and to let the same code adapt to different environments.

Why shouldn't secrets be committed to version control?

Because they remain in the repository's history indefinitely, even after later removal, and anyone with repo access (now or in the future) could see them.

What's the role of a `.env` file in local development?

It simulates real environment variables locally by defining key-value pairs that a library like dotenv loads into process.env at startup, without those secrets living in the actual source code.

What data type is a value read from `process.env`?

Always a string — even something written as PORT=3000 becomes the string "3000", so numeric or boolean-looking values need explicit conversion, like Number(process.env.PORT), before being used as anything but text.

What's the difference between `.env` and `.env.example`?

.env holds real, often secret values and is excluded from version control; .env.example lists the variable names the app expects, with placeholder or no values, and is committed so a new developer knows what to set up without ever seeing an actual secret.

Why is it good practice to make an app fail immediately at startup if a required environment variable is missing, instead of defaulting silently?

Failing loudly at startup surfaces a misconfiguration right away, in an obvious place; silently defaulting (say, to an empty string) lets the app look healthy and then fail confusingly much later, often deep inside unrelated code, when that missing value is finally used.

What does `NODE_ENV` typically control in a Node.js app?

It's a conventional variable that many libraries and the app's own code check to decide behavior — like enabling verbose logging and disabling caching in development, versus turning on compression and minimal logging in production.

If a variable is set both in the actual shell environment and in a `.env` file, which one takes effect?

Most .env loaders, like dotenv, only set a key in process.env if it isn't already present — so a value already set in the real OS/shell environment wins over whatever the .env file specifies, not the reverse.

Why is it risky to let a database URL or private API key end up in frontend/client-side code, even unintentionally?

Anything shipped to the browser is visible to anyone who opens dev tools or views the page source — a secret meant only for the backend becomes public the moment it's bundled into client-side JavaScript.

What's the difference between a build-time and a runtime environment variable in a typical deployment?

A build-time variable is baked into the compiled output when the app is built (changing it requires a rebuild); a runtime variable is read fresh each time the process starts, so it can be changed just by restarting the process with a new value, no rebuild required.

Why do frontend build tools like Vite require a special prefix (e.g. `VITE_`) before exposing an environment variable to client code?

So that only variables explicitly opted in by that prefix get bundled into the client-side output — protecting the rest of the environment, including real secrets meant only for the backend, from being accidentally shipped to the browser.

If you edit a value in `.env` while the app is already running, does the change take effect immediately?

No — environment variables are read once into process.env when the process starts; a running process keeps using whatever value it loaded at startup, so the app has to be restarted for the new value to apply.

What's a feature flag, and how does it typically relate to environment variables?

A feature flag is a toggle that turns a piece of functionality on or off without a code change; it's commonly implemented as an environment variable the code checks, letting a feature be enabled in staging but kept off in production without deploying different code.

Why might a production system use a dedicated secrets manager (like AWS Secrets Manager or HashiCorp Vault) instead of plain environment variables?

A secrets manager adds capabilities plain environment variables lack on their own — encryption at rest, fine-grained access control, audit logging of who read a secret and when, and the ability to rotate a credential without redeploying every service that uses it.

What does the "12-factor app" methodology say about configuration?

Store config in the environment, strictly separated from code — the same build/codebase should be deployable across environments purely by supplying different environment variable values, with zero code changes.

What's wrong with code like `if (process.env.DEBUG) { ... }` when the intent is to check a boolean flag?

Since every value from process.env is a string, this is truthy for any non-empty string — including the literal string "false" — so DEBUG=false still enters the debug branch; the fix is an explicit comparison, like process.env.DEBUG === "true".

Why should different environments (development, staging, production) generally use different actual secret values, not just structurally similar ones?

If the same credential is reused everywhere, a compromise in a less-protected environment, like staging, immediately exposes production too — separate values contain the blast radius of a leak to just the environment where it happened.

What's a practical way to validate that all of an app's required environment variables are present and well-formed, rather than checking each one individually wherever it's used?

Define a schema (with a library like zod or envalid, or by hand) listing every expected variable, its type, and whether it's required, and validate process.env against it once at startup — so a missing or malformed variable crashes the app immediately with one clear error, instead of failing unpredictably later.