Environment Variables & Configuration

beginner env-variables configuration secrets dotenv 12-factor

Hardcoding a database password in our source code is a recipe for disaster. Someone pushes it to GitHub, a bot scrapes it within minutes, and now our database is compromised. Environment variables solve this — they let us store configuration outside our code, where it belongs.

An environment variable is just a key-value pair set in the operating system’s environment. Our app reads it at runtime. Different environments (dev, staging, prod) get different values. Same code, different config.

Why We Use Env Vars

Three big reasons:

  1. Security — Secrets (API keys, database passwords, tokens) never touch source code or git history
  2. Flexibility — Same app, different config per environment. Dev connects to local Postgres, prod connects to a cloud database.
  3. Portability — The app doesn’t care where it runs. It just reads the environment.

Setting and Reading Env Vars

At the OS level, env vars are straightforward.

# Set an env var in the terminal (only for this session)
export DATABASE_URL="postgres://user:pass@localhost:5432/mydb"
export API_KEY="sk-abc123"

# Read it
echo $DATABASE_URL

# Set for a single command only
DATABASE_URL="postgres://localhost/test" node server.js

# See all env vars
printenv

In our code, we read them from the environment:

// Node.js — process.env
const dbUrl = process.env.DATABASE_URL;
const port = process.env.PORT || 3000;  // fallback to 3000 if not set
const isProduction = process.env.NODE_ENV === "production";

// Never log secrets, but fine for non-sensitive config
console.log(`Starting server on port ${port}`);
# Python — os.environ
import os

db_url = os.environ.get("DATABASE_URL")        # returns None if not set
port = int(os.environ.get("PORT", "3000"))      # default to "3000"
api_key = os.environ["API_KEY"]                 # raises KeyError if missing

.env Files and dotenv

Typing export for every variable is tedious. .env files let us define them all in one place.

# .env
DATABASE_URL=postgres://user:pass@localhost:5432/mydb
API_KEY=sk-abc123
NODE_ENV=development
PORT=3000

Then we use a library to load them into our app:

// Node.js — npm install dotenv
require("dotenv").config();  // loads .env into process.env
// Or in ESM: import "dotenv/config";

const dbUrl = process.env.DATABASE_URL;  // now available
# Python — pip install python-dotenv
from dotenv import load_dotenv
import os

load_dotenv()  # loads .env into os.environ
db_url = os.environ.get("DATABASE_URL")

Environment-Specific Config

For different environments, we use different .env files:

.env                  # shared defaults
.env.development      # dev-specific overrides
.env.production       # production values
.env.test             # test environment

Some frameworks (like Vite, Next.js, CRA) automatically load these based on the NODE_ENV. For vanilla Node.js, we handle it ourselves:

// Load the right .env file based on NODE_ENV
const dotenv = require("dotenv");
const env = process.env.NODE_ENV || "development";
dotenv.config({ path: `.env.${env}` });

The Golden Rule: Never Commit Secrets

This is non-negotiable. The .env file should always be in .gitignore.

# .gitignore
.env
.env.*
!.env.example          # keep an example file in git (no real values)

We do commit a .env.example file with placeholder values so new developers know what variables they need:

# .env.example — commit this to git
DATABASE_URL=postgres://user:password@localhost:5432/dbname
API_KEY=your-api-key-here
NODE_ENV=development

If we accidentally commit a secret: Don’t just delete it in the next commit. The secret is in the git history forever. We need to rotate it immediately — generate a new API key, change the password, revoke the token. Then use git filter-branch or BFG Repo Cleaner to scrub the history if needed.

The 12-Factor App Config Principle

The 12-factor methodology says: store config in the environment. Not in code, not in config files baked into the deploy. The environment is the only place that changes between deploys.

This means we should be able to open-source our entire codebase without exposing any credentials. If we can’t, our config separation isn’t good enough.

Env Vars in Docker

Docker has several ways to pass env vars to containers.

# 1. Inline with -e flag
docker run -e NODE_ENV=production -e API_KEY=abc123 my-app

# 2. From a file with --env-file
docker run --env-file .env my-app
# 3. In Dockerfile with ENV (baked into the image — not for secrets!)
ENV NODE_ENV=production
ENV PORT=3000

# 4. ARG is for build-time only — not available at runtime
ARG BUILD_VERSION=1.0
RUN echo "Building version $BUILD_VERSION"
# 5. In docker-compose.yml
services:
  app:
    environment:
      - NODE_ENV=production     # inline
      - API_KEY=${API_KEY}      # from host env or .env
    env_file:
      - .env.production         # from a file

Important: Never put secrets in ENV instructions in Dockerfiles. They get baked into the image and anyone who pulls the image can see them. Use runtime env vars (-e or env_file) instead.

Env Vars in CI/CD

Every CI/CD platform has a way to store secrets securely:

  • GitHub Actions — Settings → Secrets → Actions secrets, accessed as ${{ secrets.API_KEY }}
  • GitLab CI — Settings → CI/CD → Variables
  • Vercel / Netlify — Environment variables in project settings

These are encrypted at rest and injected at runtime. They never show up in logs (most platforms auto-mask them).

In simple language, environment variables keep our secrets out of code and let the same app behave differently in dev, staging, and production — just by changing the environment, not the code.