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โ
| File | Purpose | Commit? |
|---|---|---|
.env | Shared defaults for all environments | Only if it contains no secrets |
.env.local | Personal local overrides โ never shared | โ Always gitignore |
.env.development | Development-only overrides | โ Safe if no secrets |
.env.production | Production-only overrides | โ Safe if no secrets |
.env.test | Test environment overrides | โ Safe if no secrets |
.env.example | Template: all keys, no real values | โ Always commit |
.env.*.local | Any .local variant | โ Always gitignore |
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.
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 replacesimport.meta.env.VITE_FOOwith 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.
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):
| File | Loaded when |
|---|---|
.env.[mode].local | Always (machine-specific, highest priority) |
.env.[mode] | Always (mode-specific) |
.env.local | Always except test mode |
.env | Always (lowest priority) |
- Default modes:
development(dev server),production(build),test(Vitest). import.meta.env.MODEcontains the current mode string.import.meta.env.DEVandimport.meta.env.PRODare built-in booleans.
// โ
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:
/// <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.
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
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.
| Tool | Best for | Notes |
|---|---|---|
| GitHub Actions secrets | CI/CD credentials, deploy tokens | Aliz default for CI/CD; no cost; no runtime access |
| Google Cloud Secret Manager | Cloud Run runtime secrets | IAM-controlled, versioned, audit logs; pairs with Aliz's GCP stack |
| Doppler | Central dashboard for all envs | SaaS; GitHub Actions + Cloud Run integrations; good for larger teams |
| HashiCorp Vault | Enterprise / self-hosted | High complexity; best when already in your infrastructure |
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 withgit filter-repo(notgit rm) and every secret in it rotated immediately. Adding.envto.gitignoreafter 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 undefinedprocess.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.login 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 becauseprocess.env.TYPOreturnsundefinedrather than throwing. - Storing secrets in
localStorageorsessionStorageโ accessible to any JavaScript on the page. XSS directly exfiltrates them. Tokens should live inhttpOnlycookies. - 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โ
- Web Security Essentials โ brief overview of secrets management in context
- dotenv โ the npm package for loading
.envfiles in Node.js - Vite โ build tool with built-in env file support
- Vite Environment Variables docs โ official reference for modes and
.envloading - GitHub Actions encrypted secrets
- Google Cloud Secret Manager
- Node.js
--env-fileflag โ native env file loading since Node.js 20.6 (no package needed) - npm Security Checklist โ includes guidance on protecting
.npmrcand publish tokens - git-filter-repo โ the correct tool to purge accidentally committed secrets from Git history