Skip to main content

Environment Variables

This page covers how environment variables work in frontend and Node.js projects โ€” naming conventions, the critical distinction between build-time and runtime variables, and how to manage secrets safely in local development, CI/CD, and production. This is the deeper coverage referenced from Web Security Essentials.

.env file naming conventionsโ€‹

FilePurposeCommit?
.envShared defaults for all environmentsOnly if it contains no secrets
.env.localPersonal local overrides โ€” never sharedโŒ Always gitignore
.env.developmentDevelopment-only overridesโœ… Safe if no secrets
.env.productionProduction-only overridesโœ… Safe if no secrets
.env.testTest environment overridesโœ… Safe if no secrets
.env.exampleTemplate: all keys, no real valuesโœ… Always commit
.env.*.localAny .local variantโŒ Always gitignore
tip

The .local suffix is the universal gitignore signal. Vite, Next.js, and other frameworks all treat files ending in .local as machine-specific overrides that should never be committed. Add *.local and .env to your .gitignore at project creation.

caution

Committing a .env file is only safe if it contains zero secrets โ€” e.g., VITE_API_BASE_URL=https://api.example.com or NODE_ENV=development. When in doubt, don't commit it. Use .env.example as the documented template instead.

Build-time vs runtime variablesโ€‹

  • Build-time substitution (Vite, VITE_*): Vite statically replaces import.meta.env.VITE_FOO with the literal string value at build time. The value is embedded in the JavaScript bundle that ships to the browser. Anyone with DevTools can read it. These are public values by design.
  • Runtime environment variables (Node.js, process.env): The variable exists only in the server process at runtime. The browser never sees it. API keys, database credentials, and tokens must live here โ€” accessed only by your backend.
danger

VITE_ and NEXT_PUBLIC_ prefixes mean public โ€” not "available to the frontend". Values bundled at build time are visible to anyone who opens DevTools โ†’ Sources. Never assign a secret to an environment variable with these prefixes. If your frontend needs to call a protected API, proxy the call through your backend instead.

Vite's environment variable handlingโ€‹

Vite includes dotenv and dotenv-expand out of the box โ€” no install needed. It exposes variables to browser code through import.meta.env, but only those prefixed with VITE_.

Load order (highest priority first):

FileLoaded when
.env.[mode].localAlways (machine-specific, highest priority)
.env.[mode]Always (mode-specific)
.env.localAlways except test mode
.envAlways (lowest priority)
  • Default modes: development (dev server), production (build), test (Vitest).
  • import.meta.env.MODE contains the current mode string.
  • import.meta.env.DEV and import.meta.env.PROD are built-in booleans.
src/config.ts
// โœ… Correct: VITE_ prefix โ€” available in browser bundle
const apiBase = import.meta.env.VITE_API_BASE_URL;

// โŒ Wrong: no VITE_ prefix โ€” will be undefined in the browser
const secret = import.meta.env.API_SECRET;

TypeScript autocompletion โ€” extend ImportMetaEnv in src/vite-env.d.ts:

src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
// add further VITE_ variables here
}

interface ImportMeta {
readonly env: ImportMetaEnv;
}

GitHub Actions secretsโ€‹

  • Setting secrets: Repository-level: Settings โ†’ Secrets and variables โ†’ Actions โ†’ New repository secret. For per-environment isolation: Settings โ†’ Environments โ†’ [env name] โ†’ Add secret.
  • Using in workflows: Reference as ${{ secrets.SECRET_NAME }}. GitHub automatically masks the literal value in all log output.
  • Fork PR protection: Secrets are not passed to workflows triggered by pull requests from forks โ€” this prevents fork PRs from exfiltrating secrets.
.github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- name: Build
env:
VITE_API_BASE_URL: ${{ secrets.VITE_API_BASE_URL }}
run: npm run build

- name: Deploy to Cloud Run
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
run: # ... gcloud deploy command
caution

GitHub Actions masks the exact literal string of each secret in logs. It does not mask secrets that are base64-encoded, split across lines, or printed inside a JSON object. Avoid echoing secrets via echo $SECRET or logging full request/response bodies that may contain secret fields.

Production secrets managementโ€‹

For Aliz projects:

  • GitHub Actions secrets โ€” for build-time values and deploy credentials (GCP service account keys, npm tokens)
  • Google Cloud Secret Manager โ€” for runtime secrets accessed by Cloud Run services (database passwords, API keys). Secrets are IAM-controlled, versioned, and have audit logs.
ToolBest forNotes
GitHub Actions secretsCI/CD credentials, deploy tokensAliz default for CI/CD; no cost; no runtime access
Google Cloud Secret ManagerCloud Run runtime secretsIAM-controlled, versioned, audit logs; pairs with Aliz's GCP stack
DopplerCentral dashboard for all envsSaaS; GitHub Actions + Cloud Run integrations; good for larger teams
HashiCorp VaultEnterprise / self-hostedHigh complexity; best when already in your infrastructure
tip

On Cloud Run, mount a Secret Manager secret directly as an environment variable in the service configuration โ€” no code changes needed. The Cloud Run service account needs the secretmanager.secretAccessor IAM role on the relevant secret.

Rotation and auditingโ€‹

  • Rotate secrets on team member departure and on any suspected exposure.
  • Cloud Audit Logs records every Secret Manager access by identity and time.
  • GitHub Actions has no native secret access audit log โ€” use environment protection rules (required reviewers for production deployments) as a compensating control.

Common mistakesโ€‹

  • Committing .env โ€” the most common mistake. If it happens, the file must be purged from Git history with git filter-repo (not git rm) and every secret in it rotated immediately. Adding .env to .gitignore after the fact does not remove it from history.
  • Using VITE_* for secrets โ€” a misreading of the prefix as "make this available to my app" rather than "expose this publicly". Variables that need to reach the frontend must be non-secret.
  • Adding VITE_ to "fix" an undefined process.env โ€” accessing a server-side variable in frontend code. The fix is to move the logic to the backend, not to expose the variable in the bundle.
  • console.log in CI bypassing log masking โ€” logging an object that contains a secret field (console.log(config)) prints the secret even if GitHub masks the raw literal, because the formatted output differs from the masked string.
  • Missing .env.example โ€” new developers waste time figuring out which variables are needed, and typos in variable names cause silent failures because process.env.TYPO returns undefined rather than throwing.
  • Storing secrets in localStorage or sessionStorage โ€” accessible to any JavaScript on the page. XSS directly exfiltrates them. Tokens should live in httpOnly cookies.
  • Hardcoding secrets as default values โ€” const key = process.env.API_KEY ?? 'sk-real-key-here' defeats the entire point of environment variables and often ends up committed.

Further Readingโ€‹