Skip to main content

🔑 Environment Variables

Your API's database URL is localhost on your laptop and a private cloud host in production. Your JWT secret must never appear in a Git commit. The same code has to run in both worlds without editing a line. Environment variables are how professional apps pull that off — configuration lives outside the code, injected fresh for each place it runs.

Week 10 · Monday: Connecting Frontend to Backend · Lecture 3

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the twelve-factor principle of separating config from code
  • Load server config from a .env file with dotenv and read it via process.env
  • Expose client config in Vite with import.meta.env and the required VITE_ prefix
  • Explain why every frontend env var is public and keep real secrets server-side
  • Validate required variables at startup so a misconfig fails loudly, not silently
  • Keep .env out of Git while committing a .env.example template

Estimated Time: 60 minutes

Practice: Build a central config module with validation and a matching .env.example.

In This Lesson

Why Config Lives Outside Code

An environment variable is a value the operating system (or your tooling) hands to a running process. It is not compiled in — it is supplied from the outside, so the same build behaves differently depending on where it runs.

Think of the dials on the outside of an oven. You can raise the temperature without opening the oven or rebuilding it. Environment variables are those dials for your app: the code (the oven) stays sealed, while the settings (the dials) change per environment. This is the config principle from the Twelve-Factor App methodology, and it buys you four things at once:

  • Security — secrets like API keys and DB passwords never sit in source code
  • Per-environment config — dev, staging, and prod each get their own values
  • Deploy flexibility — change a value without rebuilding or editing code
  • Container friendliness — Docker and cloud platforms inject config exactly this way
flowchart LR A["Same code build"] --> B["Dev: localhost DB"] A --> C["Staging: staging DB"] A --> D["Prod: production DB"]

Why it matters: hardcode mongodb://localhost and your app cannot ship. Hardcode a secret and it lives in your Git history forever. Externalizing config is the difference between a demo and a deployable application.

Server-Side: dotenv & process.env

In Node.js, environment variables arrive on the global process.env object as strings. In development you rarely set them by hand — you put them in a .env file and load them with the dotenv package.

The .env file

// .env  (project root — NEVER committed to Git)
NODE_ENV=development
PORT=3001
DATABASE_URL=mongodb://localhost:27017/myapp
JWT_SECRET=super-secret-change-me
CORS_ORIGINS=http://localhost:5173

Loading it — first line of your entry file

// server.js — load env vars BEFORE anything else reads them.
require('dotenv').config();

const express = require('express');
const app = express();

// process.env values are always STRINGS — parse numbers/booleans yourself.
const port = parseInt(process.env.PORT, 10) || 3001;

app.get('/', (req, res) => {
  res.send(`Running in ${process.env.NODE_ENV} mode`);
});

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

⚠️ Two traps with process.env

1. Load early. dotenv.config() must run before any module reads process.env, or those modules see undefined. Put it at the very top. 2. Everything is a string. process.env.PORT is "3001", not 3001, and process.env.DEBUG is "false" — a truthy string! Convert with parseInt and compare with === 'true'.

💡 Node's built-in --env-file

Modern Node (v20.6+) can load a .env file without the package: node --env-file=.env server.js. The dotenv package is still ubiquitous and works everywhere, so it remains the safe default — but it is good to know the runtime now does this natively.

A Central Config Module

Sprinkling process.env.WHATEVER throughout your codebase scatters defaults and makes typos silent. The professional pattern is one config module that reads, parses, validates, and re-exports everything. The rest of the app imports that, never process.env directly.

// config.js — the single source of truth for configuration.
require('dotenv').config();

// Fail fast: if a required secret is missing, crash at startup
// with a clear message instead of a mysterious error at 3am.
const required = ['DATABASE_URL', 'JWT_SECRET'];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
  throw new Error(`Missing required env vars: ${missing.join(', ')}`);
}

module.exports = {
  env: process.env.NODE_ENV || 'development',
  port: parseInt(process.env.PORT, 10) || 3001,
  databaseUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
  // Turn a comma-separated string into a real array for the CORS allowlist.
  corsOrigins: (process.env.CORS_ORIGINS || 'http://localhost:5173')
    .split(',')
    .map((o) => o.trim()),
  debug: process.env.DEBUG === 'true' // explicit string compare
};
// server.js — clean, and every value is already parsed and validated.
const config = require('./config');
const express = require('express');
const cors = require('cors');

const app = express();
app.use(cors({ origin: config.corsOrigins, credentials: true }));

app.listen(config.port, () =>
  console.log(`Server running in ${config.env} mode on port ${config.port}`)
);

✅ Why validation up front matters

A forgotten JWT_SECRET without validation might not surface until a user tries to log in — in production. With the startup check, the process refuses to boot and tells you exactly which variable is missing. Loud, early failure beats a silent, late one every time. Notice this is the same corsOrigins allowlist from the CORS lesson, now sourced properly.

Client-Side: Vite & import.meta.env

Frontend code runs in the browser, not on a server, so there is no process.env at runtime. Instead, your build tool bakes selected variables into the bundle at build time. In Vite you read them from import.meta.env, and only variables prefixed with VITE_ are exposed.

Build tool bakes VITE_ variables into the bundle served to the browser .env file VITE_API_URL Vite build bakes values in Bundle sent to browser Values are frozen at build time — visible to anyone
The dev/prod split for the client: values are chosen at build time and shipped inside the public bundle.
// .env  (in the frontend project)
VITE_API_URL=http://localhost:3001/api
VITE_FEATURE_NEW_UI=true
// Anywhere in your Vite app — note import.meta.env, not process.env.
const API_URL = import.meta.env.VITE_API_URL;

async function getUsers() {
  const res = await fetch(`${API_URL}/users`);
  if (!res.ok) throw new Error('Failed to load users');
  return res.json();
}

// Booleans arrive as strings here too — compare explicitly.
const newUiEnabled = import.meta.env.VITE_FEATURE_NEW_UI === 'true';

💡 The dev/prod split, made concrete

Vite auto-loads .env.development when you run vite and .env.production when you run vite build. So VITE_API_URL can point at localhost:3001 in dev and https://api.example.com in prod with zero code changes. See the Vite env & modes guide. (Create React App uses the same idea with a REACT_APP_ prefix and process.env.)

Frontend Vars Are Never Secret

This is the single most important idea in the lesson, and the one beginners get wrong. A VITE_ variable is baked into the JavaScript bundle that ships to every visitor. Anyone can open DevTools, view the source, and read it. There is no such thing as a secret frontend environment variable.

⚠️ What belongs where

Safe on the client (public by nature): the API base URL, feature flags, a public analytics ID, a publishable Stripe key. Server-only (never in a VITE_ var): database passwords, JWT secrets, private API keys, OAuth client secrets. If leaking it would hurt, it lives on the server.

// ❌ NEVER — this secret is now readable by every visitor.
const stripeSecret = import.meta.env.VITE_STRIPE_SECRET_KEY;

// ✅ Correct — the secret stays on the server; the browser calls
// your own endpoint, and the server talks to Stripe with the secret.
async function pay(amount) {
  const res = await fetch('/api/checkout', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ amount })
  });
  return res.json();
}

Why it matters: the "keep secrets server-side" rule from the API-integration lesson is exactly this rule. When a value must stay private, the browser calls your server, and your server — reading a real, non-VITE_ env var — does the sensitive work. The BFF pattern exists largely to make this clean.

.env, .gitignore & .env.example

The .env file holds real values, some of them secret, so it must never enter version control. But teammates still need to know which variables to set — so you commit a sanitized template instead.

// .gitignore — keep every real env file out of Git.
.env
.env.local
.env.*.local
// .env.example — COMMITTED. Keys with blank or placeholder values,
// so a new teammate copies it to .env and fills in the blanks.
NODE_ENV=development
PORT=3001
DATABASE_URL=mongodb://localhost:27017/myapp
JWT_SECRET=            # generate your own
CORS_ORIGINS=http://localhost:5173

💡 The onboarding ritual

A new developer clones the repo and runs cp .env.example .env, then fills in secrets. The template documents the full set of required variables (pairing perfectly with your startup validation), while the real secrets stay off GitHub. In production you skip .env entirely — the hosting platform (Netlify, Render, Docker, Kubernetes) injects the variables directly into process.env.

⚠️ If a secret ever hits Git, rotate it

Deleting a committed secret in a later commit does not remove it — it lives on in history, and bots scan public repos for exactly these patterns within minutes. The only real fix is to rotate (regenerate) the leaked credential immediately. Prevention beats cleanup: get .env into .gitignore on day one.

Practice & Quiz

🏋️ Exercise 1: A validated config module

Goal: Write config.js that loads dotenv, throws if DATABASE_URL or JWT_SECRET is missing, and exports port (a number, default 3001) and debug (a real boolean).

require('dotenv').config();
// TODO: validate required vars, then export the parsed config object.
module.exports = {
  // port: ...,  debug: ...,  databaseUrl: ...,  jwtSecret: ...
};
💡 Hint

Build a required array, filter it against process.env, and throw if any are missing. Use parseInt(..., 10) || 3001 for the port and === 'true' for the boolean.

✅ Solution
require('dotenv').config();

const required = ['DATABASE_URL', 'JWT_SECRET'];
const missing = required.filter((k) => !process.env[k]);
if (missing.length > 0) {
  throw new Error(`Missing required env vars: ${missing.join(', ')}`);
}

module.exports = {
  port: parseInt(process.env.PORT, 10) || 3001,
  debug: process.env.DEBUG === 'true',
  databaseUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET
};

🏋️ Exercise 2: Which variable is safe on the client?

Goal: Of these four, which may live in a VITE_ variable, and which must stay server-side? DATABASE_URL, API base URL, JWT_SECRET, feature flag.

✅ Solution

Client-safe: the API base URL and the feature flag — both are public by nature. Server-only: DATABASE_URL and JWT_SECRET — leaking either compromises your whole backend. Rule of thumb: if exposure would cause harm, it never touches a VITE_ variable.

🎯 Quick Quiz

Question 1: In a Vite app, how do you read an environment variable?

Question 2: Why can't you store a database password in a VITE_ variable?

Question 3: What is the value of process.env.PORT when .env has PORT=3001?

Best Practices & Pitfalls

✅ Do

  • Call dotenv.config() (or use --env-file) at the very top of your entry file
  • Funnel all config through one validated config module
  • Parse types explicitly: parseInt for numbers, === 'true' for booleans
  • Commit a .env.example template; add .env to .gitignore
  • Keep real secrets server-side; only public values get a VITE_ prefix

❌ Don't

  • Commit a real .env — and if you ever do, rotate the secret, don't just delete it
  • Treat any frontend variable as private; the bundle is public
  • Read process.env in dozens of files; centralize it
  • console.log(process.env) — it dumps every secret into your logs
  • Assume env values are typed; they are always strings

📖 Beyond .env: secret managers

For serious production systems, plain env vars give way to dedicated secret stores — AWS Secrets Manager, Google Secret Manager, Azure Key Vault, HashiCorp Vault. They add access control, audit logs, and automatic rotation. The mental model is identical: config stays outside the code. You just fetch it from a vault instead of a file.

Summary

🎉 Key Takeaways

  • Environment variables keep config separate from code so one build runs everywhere
  • Server-side: load with dotenv, read from process.env (always strings)
  • Client-side (Vite): read import.meta.env; only VITE_-prefixed vars are exposed
  • Every frontend variable is public — real secrets stay on the server
  • Validate required vars at startup so misconfig fails loud and early
  • Git-ignore .env, commit .env.example, and rotate any leaked secret

📚 Additional Resources

🚀 What's Next?

You've connected frontend to backend with CORS, structured the calls with clean integration patterns, and configured both sides safely. Next the connection goes live and two-way: WebSocket Fundamentals — persistent, real-time channels for chat, notifications, and live dashboards.

🎉 Config, conquered!

Your app now runs the same code in every environment — and your secrets stay secret.