Week 14

Understanding CI/CD

Environment Configuration

Security in Production

Wrapping up

Practice

Assignment

Frontend Track

What environment variables are, and why they exist

The same codebase runs in several different places: your laptop, the CI pipeline, and production. Each of those places needs slightly different behaviour — a different API URL, different logging, different feature toggles — without you maintaining separate copies of your code for each one.

Environment variables solve this. They're values injected from outside your code, so the same source stays identical everywhere while the values it reads change underneath it.

Where you read them from depends on where your code runs. Your portfolio is a Vite app, which means two different runtimes are involved:

Vite also gives you three ready-made values for the client: import.meta.env.DEV and import.meta.env.PROD are real booleans, and import.meta.env.MODE is a string ("development", "production", or whatever mode you're building for).

⚠️ process.env values (used in your server code) are always strings, or undefined — never booleans, and never numbers. process.env.MAX_RETRIES is the string "3", not the number 3. Every value needs to be explicitly parsed or compared against a string. import.meta.env.DEV/.PROD are the one exception — Vite gives you those two as actual booleans.

The idea behind this: the Twelve-Factor App

This week's environment-variable habits aren't something specific to Vite or Vercel — they come from the Twelve-Factor App, a widely-referenced set of principles for building software that's easy to deploy and scale, written by engineers at Heroku back in 2011 and still the default mental model most teams reach for today.

Factor III, specifically, is "Config": store configuration in the environment, never in the code. The test the methodology proposes is refreshingly concrete — could you make your codebase public right now, without exposing a single credential? If the answer is no, something that should be an environment variable is hardcoded instead.

💡 Key takeaway: this isn't a tool-specific convention — it's an industry-wide principle that Vite, Next.js, Vercel, and pretty much every serious hosting platform happen to implement, each in their own way.

Configuration vs. secrets

Not every environment variable is protecting something, and it's worth explicitly separating two categories that tend to get lumped together under the umbrella of "env vars":

The VITE_ prefix decision from the next section is really this distinction wearing a technical costume: configuration is safe to prefix and expose to the browser; secrets never are. Whenever you add a new environment variable to your project, ask which category it belongs to before deciding whether it gets the prefix — not after.

Public vs private environment variables in Vite

Vite draws a hard line between two kinds of environment variables:

This isn't a minor naming convention — it's the entire security boundary. A value like VITE_API_URL is meant to be public; there's nothing to protect. A value like a mailing-service secret key must never carry that prefix, because the moment it does, it ships to every visitor's browser.

⚠️ If you're ever unsure whether something is a secret, assume it is. The cost of an unnecessary private variable is a few extra lines of server code. The cost of an accidentally public secret is someone else spending your API quota, or worse.

Build-time vs runtime: when a new value actually takes effect

Here's a detail that trips up almost everyone the first time: Vite doesn't look up import.meta.env.VITE_X while your app is running in the browser. It replaces it, literally, with the actual string value, at build time — before the JavaScript is ever bundled or shipped anywhere.

That has a real consequence: changing a VITE_ variable's value in Vercel's dashboard does nothing to your live site until you trigger a new deployment. The old value is already baked directly into the JavaScript file sitting on Vercel's servers; there's no environment left to re-read at runtime, the way there is for server code reading process.env.

⚠️ If you change a VITE_ variable and nothing seems to happen, this is almost always why. Push any commit, or manually trigger a redeploy from Vercel's dashboard, to force a fresh build. A server restart wouldn't help here even if you could trigger one — the value isn't read at runtime at all.

Writing environment-aware utility functions

Reading import.meta.env directly all over your components makes it easy to typo a variable name or forget a fallback. Instead, wrap it in small, pure, defensive functions:

// src/config/featureFlags.js

export function isFeatureEnabled(flagName) {
  const value = import.meta.env[`VITE_FEATURE_${flagName.toUpperCase()}`];
  return value === "true";
}

export function getEnvironmentLabel() {
  if (import.meta.env.PROD) return "Live";
  if (import.meta.env.MODE === "test") return "Test";
  return "Local Development";
}

Notice the shape of both functions: each reads import.meta.env, each returns a plain value (never throws), and each has sensible behaviour even when the variable is missing entirely — getEnvironmentLabel quietly falls back to "Local Development" rather than returning undefined or crashing.

💡 Key takeaway: treat import.meta.env the way you'd treat any untrusted input. Wrap it once, in one place, in functions that are easy to unit test — don't scatter raw import.meta.env.X checks through your components.

Validating your environment at startup

A missing environment variable is one of the most common ways a working app breaks the moment it's deployed somewhere new. It ran fine locally, because your .env.local had everything it needed — and then it silently misbehaves in preview or production, because one variable was never added there.

The fix is to check for what you need once, up front, instead of discovering it's missing halfway through unrelated code:

// src/config/env.ts

function requireEnv(key: string): string {
  const value = import.meta.env[key];
  if (!value) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
  return value;
}

export const apiUrl = requireEnv("VITE_API_URL");

This fails loudly and immediately, with a message that names the exact problem — instead of failing quietly three components deep with a confusing undefined that could mean almost anything.

💡 Worth knowing: larger projects often reach for a schema-validation library (like Zod) to validate their whole set of environment variables at once, with types included for free. The principle is identical either way: check early, fail loudly, name the problem precisely.

Vite's env file loading order

Vite doesn't read just one .env file — it looks for several, in a specific order, and later files override earlier ones where they overlap:

File Loaded
.env Always, in every mode
.env.local Always, except when running tests — never committed
.env.[mode] (e.g. .env.production) Only when building for that mode
.env.[mode].local Only in that mode, never committed

"Mode" here usually corresponds to development or production, matching how you ran Vite (vite dev vs vite build). It's worth double-checking this isn't automatically the same thing as your final deployment environment if your setup ever grows a staging tier in between the two.

Setting environment variables where they belong

Locally, that means real secrets and machine-specific values belong in .env.local — and that file must be in .gitignore. Committing it would mean committing whatever secrets it contains straight into your Git history, where they're nearly impossible to fully remove later.

In production, you set the same variables in your hosting platform's dashboard. On Vercel, that's Project Settings → Environment Variables, where you can scope a value to production, preview, or development independently — so a preview deployment can safely point at a staging API while production points at the real one.

⚠️ The most common "works on my machine" bug in this space: you add a new variable to .env.local, everything works perfectly locally, you push — and the deployed build breaks, because nobody added the same variable to Vercel's dashboard. Whenever you add an environment variable, add it in both places in the same sitting, not "later."

.env.example: documenting what's needed, without leaking it

If .env.local is gitignored, how does a new contributor — or you, six months from now, setting this project up on a new laptop — know which variables the project even needs? The convention is a second file, .env.example, which is committed:

# .env.example
VITE_API_URL=
VITE_GITHUB_STATS_URL=

It lists every variable name your project reads, with no real values — just enough for someone to copy it to .env.local and fill in the blanks themselves. It's documentation that can't go stale, because it lives right next to the code that actually reads it.

💡 A nice side effect: reviewing a pull request that adds a new environment variable is a good moment to ask "did .env.example get updated too?" It's an easy thing to forget, and an easy thing to catch in review.

A worked example: adding a new environment variable end-to-end

Let's put the whole chapter together with one concrete walk-through: your portfolio needs to call a new "GitHub stats" API to show your contribution graph, and the base URL it calls needs to differ between environments.

  1. Add it locally, in .env.local: VITE_GITHUB_STATS_URL=http://localhost:4000/stats
  2. Read it through a wrapped function, not scattered inline through your components:
// src/config/githubStats.js
export function getGithubStatsUrl() {
  return import.meta.env.VITE_GITHUB_STATS_URL || "<https://api.github.com>";
}
  1. Test it, covering both the configured case and the fallback
  2. Add the same variable to Vercel — Project Settings → Environment Variables — with the real production URL, scoped to Production (and a separate staging URL scoped to Preview, if your setup has one)
  3. Push, and let the pipeline do the rest: CI installs, lints, tests, and builds; Vercel then builds again with the production value baked in, and deploys it

Notice what's absent from this list: nobody SSHed into a server, manually edited a config file after deploying, or had to remember to update something a week later. Every step lives either in your codebase — reviewed, tested, versioned — or in a dashboard built for exactly this purpose.

💡 This is the payoff of the whole chapter. Once this pattern is in place, adding the tenth environment-aware value is exactly as easy as adding the first one.

Additional Resources

Reading


The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

CC BY-NC-SA 4.0 Icons

Built with ❤️ by the HackYourFuture community · Thank you, contributors

Found a mistake or have a suggestion? Let us know in the feedback form.