Short answer: a .env file feeds values to your own machine. A hosting dashboard feeds values to your builds and your servers. Nothing moves between the two on its own, so a value you set in only one of them is the usual reason an app works locally and fails in production.
An environment variable is a named string supplied to a process or build outside the application source. A .env file is one way local tooling can supply that value. A hosting dashboard is another. Neither location automatically synchronizes with the other, and neither makes a value secret after a framework bundles it into browser code.
The practical answer to “where should I set this variable?” depends on four facts: which framework reads it, whether browser or server code uses it, whether it is evaluated during the build or while the app runs, and which deployment scope needs the value.
What an environment variable actually is
At the operating-system level, a process receives key-value strings such as DATABASE_URL or NODE_ENV. In Node.js, server code commonly reads them through process.env. Node’s environment-variable documentation also defines a .env text format, but a file sitting in a project does not populate every runtime by itself. Node needs its --env-file option or programmatic API; frameworks and packages can load files under their own rules.
Values arrive as strings. FEATURE_ENABLED=false is the string "false", which is truthy in JavaScript unless the application parses it. The same applies to numbers, JSON, URLs, and comma-separated lists.
const raw = process.env.FEATURE_ENABLED;
const featureEnabled = raw === 'true';
An unset name normally reads as undefined. A present but empty value reads as an empty string. Validation should distinguish those states when an empty string is invalid.
dotenv and the other loaders
A .env file does nothing on its own. Something has to read it and push the values into the process. That something is a loader, and which one you have depends on your stack.
- dotenv is the historical default in Node. You call
require('dotenv').config()orimport 'dotenv/config'near the top of your entry file, and it loads variables from a.envfile intoprocess.env. - dotenv-expand adds
${VAR}expansion on top of dotenv, soDATABASE_URL="postgres://${USERNAME}@localhost/my_database"resolves. Plain dotenv leaves that text alone. - cross-env sets a variable inline in an npm script on any platform. It exists because Windows command prompts choke on the
NODE_ENV=production npm run buildform. - Node itself has
--env-filebuilt in since v20.6.0, and--env-file-if-existssince v22.9.0. No package needed. - Bun reads
.env, then.env.productionor.env.developmentor.env.test, then.env.localwith no configuration. Deno does not: it needs--env-fileor the standard library’s dotenv module. - Vite, Next.js, and Astro load
.envfiles themselves during dev and build. Do not stack dotenv on top of them.
The point that saves you an afternoon: a loader only runs where you call it. A .env file that stays on your machine changes nothing inside a browser bundle and nothing on a host, because neither one runs your loader against it. A hosted build does run its loader, so a .env file you commit or upload to the host is read there like any other input, which is why only browser-safe values belong in one.
What a .env file is and what goes in it
A .env file is a plain text file in your project root, one KEY=value pair per line. The name is only an extension, with nothing before the dot, which is why it is hidden by default on macOS and Linux. env is short for environment. The operating system knows nothing about this file: it is a convention that loaders agree to look for.
Here is a real one.
# Server-side only. Never give these a public prefix.
DATABASE_URL=postgres://user:pass@localhost:5432/app
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6...
# Safe in the browser. The VITE_ prefix says so out loud.
VITE_API_URL=https://api.example.com
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6...
APP_NAME="Acme Launch Console"
SENTRY_DSN=
The format rules that actually trip people up:
KEY=valueis the portable convention. Node ignores spaces around the equals sign, but other loaders can behave differently, so check the loader your app uses.#starts a comment. A#inside an unquoted value can be read as the start of one, so quote values that contain it.- Quotes are only needed when the value has spaces or newlines.
APP_NAME="Acme Launch Console"needs them. A URL does not. ${VAR}expansion is loader-specific, not universal. Plain dotenv does not expand it, dotenv-expand does, and hosts generally do not.SENTRY_DSN=is not the same as leaving the line out. It sets an empty string, which is why validation should reject empty values and not only missing ones.- A leading
exportis not needed by most loaders, although several tolerate it.
.env.example and naming rules
Commit a .env.example that lists every key with no values. It is the onboarding contract: a new machine, a new teammate, or a coding agent copies it to .env and fills it in, and a missing key becomes obvious at setup instead of a crash three screens into the app.
- Use
UPPER_SNAKE_CASEas a portable convention. It is not a universal loader requirement. - Avoid leading digits and dashes. Some loaders reject those names and some shells cannot export them.
- Treat
VITE_,NEXT_PUBLIC_, andPUBLIC_as public markers, not naming style. Adding one is a decision to ship that value to the browser. - Keep the same names in local, preview, and production. Renaming per environment is how a preview deployment ends up reading nothing.
Set one by hand on macOS, Linux, and Windows
You do not need a file. Every shell can set a variable directly, and this is what the host is doing for you under the dashboard.
| Shell | Set it | Read it back |
|---|---|---|
| bash or zsh (macOS, Linux) | export API_KEY=value | echo $API_KEY |
| Windows Command Prompt | set API_KEY=value | echo %API_KEY% |
| Windows Command Prompt, persistent | setx API_KEY value | echo %API_KEY% in a new window |
| PowerShell | $Env:API_KEY = "value" | $Env:API_KEY |
And how long each one lasts:
| Shell | How long it lasts |
|---|---|
| bash or zsh (macOS, Linux) | The current shell and anything it starts. Add the line to ~/.zshrc or ~/.bashrc to get it in every new shell. |
| Windows Command Prompt | The current console window only. |
| Windows Command Prompt, persistent | Written to the registry. Microsoft is explicit that variables set with setx are available in future command windows only, not the current one. |
| PowerShell | The current session. Put the line in your PowerShell profile ($PROFILE) to get it every time. |
Two rules cover all four.
First, precedence: a value already in the shell environment beats the same name in a .env file. Node’s own docs put it plainly, saying that if the same variable is defined in the environment and in the file, the value from the environment takes precedence. Vite behaves the same way. So does every host, because a host injects its variables into the process environment before your loader ever runs. If a .env edit appears to do nothing, check whether the name is already exported in your shell.
Second, case: Windows treats variable names as case-insensitive, and macOS and Linux do not. Microsoft’s PowerShell documentation calls this out directly, and it is why Api_Key can work on a laptop and resolve to nothing on a Linux build server.
.env and the hosting dashboard solve different scopes
A local .env, .env.local, or mode-specific file supplies values to local commands according to the framework’s loading order. A host stores values for remote builds and running services. Most hosts also split them by environment: development, preview or branch deployments, staging, and production can each receive a different value for the same name.
| Location | What it affects |
|---|---|
| Local .env files | Local development, tests, or local builds when the selected framework or command loads that file |
| Host build variables | The remote build step; public framework prefixes can be compiled into the browser bundle |
| Host runtime variables | Server processes, functions, or edge runtimes while they handle requests |
| Preview or branch scope | Non-production deployments; it must be configured separately from production when the host separates scopes |
| Production scope | New production builds or running services, subject to the host’s apply and restart rules |
This is why identical source can behave differently. The production build may have received a different public API URL. A preview function may be missing a server key. A long-running service may still hold the old value in memory because nobody restarted it.
The code is identical in both places. The config is what changes, and the variable you set once, on your laptop, is the one production never got.
Framework prefixes decide what reaches the browser
A variable’s name can trigger framework behavior. Public prefixes are exposure controls, not decoration.
Vite and older Lovable projects
Vite exposes VITE_* variables through import.meta.env and replaces them at build time. Their values are included in client-side source after bundling, so VITE_STRIPE_SECRET_KEY is a leaked secret even if the host masks it in the dashboard.
Vite loads .env, .env.local, .env.[mode], and .env.[mode].local with documented precedence. Existing process variables take priority. It reads the files when Vite starts, so restart the development server after changing them. A deployed static build needs a new build to receive a changed VITE_* value.
Lovable adds an important exception to blanket .env advice. Lovable’s Secrets documentation says older Vite-based Lovable projects keep browser-safe VITE_* values in the project’s committed .env file so previews and published builds can use them. Private backend values belong in Lovable Secrets, whose values are write-only and injected only into server-side code. Do not put private credentials into that committed .env file.
Next.js
Next.js loads .env* files into process.env for server use. Names beginning with NEXT_PUBLIC_ are inlined into browser JavaScript during next build and remain frozen in that build. Changing the dashboard value does not rewrite an existing deployment.
Unprefixed variables stay server-side when read from server code. In the App Router, dynamically rendered server code can read runtime values. Static generation and build-time code still capture what existed during the build. Decide from the code path, not from the variable name alone.
Astro
Astro supports Vite’s import.meta.env behavior and exposes only PUBLIC_* names to client code by default. Its astro:env API can declare a schema with client/server context, public/secret access, type conversion, required fields, and defaults. Runtime access depends on the SSR adapter: a Node adapter can use Node environment behavior, while Deno and Cloudflare have their own runtime APIs.
For a static Astro site, values used to render pages are build inputs. For an SSR route, a server-only value can be read at request time when the adapter supports it. The Astro environment guide documents both paths and its validation schema.
Newer Lovable projects
Lovable’s current FAQ says projects created from 13 May 2026 use TanStack Start with server-side rendering, while older projects use React and Vite. Do not apply a Vite-only migration recipe to a newer project. Inspect package.json, the server routes, and the generated environment lookups, then follow the current Lovable deployment guide for that stack.
Base44, Bolt, v0, Replit, and Claude Code projects
Lovable is not the only generator, and each tool puts the same two ideas (a public prefix and a private store) in a different place.
| Tool | Public prefix | Private store |
|---|---|---|
| Bolt | VITE_*, because the frontend is Vite. Bolt’s database guide uses VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY. | Server side only. That same guide also lists VITE_SUPABASE_SERVICE_ROLE_KEY, and a name in that shape ships a full-access key to the browser. Do not use it. |
| v0 | NEXT_PUBLIC_*. v0’s docs say client-side variables must carry that prefix. | Unprefixed variables on the connected Vercel project. They belong to the project, so every chat under it sees them. |
| Base44 | Not a documented prefix. Assume anything the frontend can read is public. | Base44 secrets, set with base44 secrets set KEY=value or --env-file, read inside backend functions through secrets.get(). Setting one redeploys the functions that reference it. |
| Replit | Whatever your framework uses. Replit adds no prefix of its own. | The Secrets tool, which encrypts the value and hands it to the app as an environment variable. |
| Claude Code and other agent-written projects | Whatever the framework you asked for uses. | Nothing is configured for you. The agent writes the lookup and leaves both .env and the host dashboard to you, so check both before the first deploy. |
Build-time and runtime variables behave differently
Build-time values become inputs to generated output. Public frontend values are often literal strings in JavaScript files delivered to the browser. Static pages can also be generated from an unprefixed server value during a trusted build without exposing the raw value, although its effect appears in the output.
Runtime values are read by a server process or function after deployment. A long-running Node service usually needs a restart or new deployment to receive a changed process environment. Serverless hosts typically attach a snapshot of variables to each deployment, so old deployments keep old values.
| Build-time value | Runtime value |
|---|---|
| Read during npm run build or an equivalent command | Read at process start or while a server request runs |
| Requires a rebuild and redeploy after a dashboard change | Requires the host’s restart, redeploy, or staged apply action |
| Can enter the browser bundle through a public prefix or client path | Stays server-side unless application code returns, logs, or otherwise discloses it |
| Public inlined values stay frozen in the built artifact | A server artifact can read a different value in each runtime environment |
- 01 For a browser-public value, set it in the build environment and create a new build.
- 02 For a server runtime value, set it in the exact service or function scope and follow that host’s restart or deployment rule.
- 03 For a preview, add the preview value separately unless the host explicitly inherits it.
- 04 For production, verify the new deployment rather than checking only that the dashboard saved the value.
Host scopes and apply rules
The dashboard label “environment variable” hides meaningful host differences.
| Host | Current apply behavior |
|---|---|
| Vercel | Development, Preview, Production, and custom scopes can differ. Changes apply to new deployments; previous deployment URLs keep their old values. |
| Netlify | Variables can be scoped to builds, functions, and deploy contexts. Create a new deploy to apply updated build or function values. |
| Railway | Variables are available to builds and running services. Adding, changing, or removing one creates staged changes that must be reviewed and deployed. |
| Render | Values can sit on one service or in a shared environment group, and a service-level value beats a linked group value of the same name. Saving offers save only, save and deploy, or save and rebuild; with save only, the service keeps the old values until its next deploy. |
| Heroku | Called config vars. Setting or removing one restarts the app and creates a new release, so no separate deploy step is needed. |
| Supabase | Edge Function secrets are set in the dashboard or with the CLI, and are available to functions immediately without a redeploy. SUPABASE_ANON_KEY is safe in a browser when Row Level Security is on; SUPABASE_SERVICE_ROLE_KEY must never reach one. |
| Cloudflare Pages and Workers | Plaintext vars and secrets are separate things: secret values are not visible in Wrangler or the dashboard once you define them. Pages sets values separately for build time and runtime, and for preview and production. |
| Replit | The Secrets pane encrypts each value and exposes it to the app as an environment variable. A process already running still needs a restart before it sees a new one. |
| A VPS or container | The process receives values when it starts. Replace or restart the process; a static image with public build values must be rebuilt. |
Host behavior above verified 2026-08-05. Recheck before a production change: these rules are product-specific and they move.
Vercel’s environment-variable documentation, Netlify’s environment overview, Railway’s variables guide, Render’s environment guide, Heroku’s config vars article, Supabase’s Edge Function secrets page, Cloudflare’s Workers secrets page, and Cloudflare’s Pages environment variables and bindings page are the source of truth for those apply rules.
Supabase environment variables are the case where the defaults matter most. As of August 2026 its secrets page lists SUPABASE_URL, SUPABASE_DB_URL, SUPABASE_PUBLISHABLE_KEYS, SUPABASE_SECRET_KEYS, and SUPABASE_JWKS as already available inside every Edge Function, with the older SUPABASE_ANON_KEY and SUPABASE_SERVICE_ROLE_KEY names still listed, so anything you set yourself sits alongside those rather than replacing them.
What the error message is actually telling you
You search the string on your screen, not the concept behind it. Here is what each one usually means when a variable is the cause.
| What you see | What it usually means |
|---|---|
ReferenceError: process is not defined | Browser code is reading process.env in a Vite build (Lovable and Bolt projects included). process only exists in Node. Decide first whether the value is private: a private key stays on the server behind a route, and only browser-safe configuration moves to a VITE_-prefixed name read through import.meta.env. |
Cannot read properties of undefined (reading 'env') | The same problem one step earlier: the object you are reading env from resolved to undefined in that runtime. Work out which runtime the file actually executes in before changing anything. |
Error: supabaseUrl is required | createClient() received undefined. The name in your code and the name in your .env or dashboard do not match, or the variable was never added to the scope that produced this deployment. |
Invalid API key from a provider | The variable is set, just to the wrong value. A test key in production, a production key in preview, or a key from a different project all land here. |
ENOTFOUND undefined, or a request to undefined/api | A public base-URL variable was missing at build time, so the literal string undefined got baked into the bundle. Set it and rebuild; restarting will not help. |
Missing required environment variable: DATABASE_URL | Your own startup guard, working exactly as designed. Set the name it printed, in the scope it printed from, then redeploy. |
The first two are the same lesson twice: a browser has no process object, so any variable your frontend reads has to arrive through the framework’s public channel and is therefore public. The Invalid API key row is the one that can come from five different services at once, and working out which key is the one being refused starts from the exact words rather than from the variable.
Sometimes there is no error text to search at all, and what you end up typing is .env file not working. The same table still applies, because the file only has an effect when a loader reads it in that runtime, the name matches exactly, and the value reached the scope that produced the build you are looking at.
Validate configuration before serving traffic
Required server configuration should fail during build or startup with the missing variable’s name, never its value. Optional integrations can disable themselves deliberately, but their disabled state should be visible in logs or a health check.
function requiredEnv(name) {
const value = process.env[name];
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
const databaseUrl = new URL(requiredEnv('DATABASE_URL'));
const port = Number(requiredEnv('PORT'));
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT must be an integer between 1 and 65535');
}
Validate URLs, numbers, booleans, enums, and JSON after reading them. Avoid a production fallback for signing keys, database URLs, encryption keys, payment credentials, or authorization configuration. A default such as SESSION_SECRET=development keeps the app running under a predictable secret and hides the deployment error.
I have watched that exact failure in the field. In one health-records API I audited, the code signing a user’s login cookie was supposed to read a session secret from the environment. That variable was unset in every deploy configuration I found, and the app did not fail to start over it: it fell back to a fixed phrase printed directly in the source and kept issuing cookies anyway. Nothing crashed, no startup guard refused to boot, and “configured” looked identical to “not configured” from every place you would normally check. A generic feature flag defaulting to false is a reasonable fallback. A signing secret defaulting to a string anyone can read in your own source leaves the app pretending it is still configured.
Do not print all of process.env during debugging. Logs, build output, exception trackers, and support exports can become a second disclosure path. Report the missing name and scope, never the value.
If you committed .env to GitHub
Rotate first. Everything else comes second.
Deleting the file in a new commit does not remove the value. It stays in the commit history, in every clone, in every fork, and in the index of anything that already scraped the repository. Until the credential is revoked at the provider that issued it, the leak is live, no matter how clean the current tree looks.
- 01 Revoke or rotate every credential in that file, at the provider that issued each one. Do this before you touch Git.
- 02 Check whether the repository was ever public, and for how long. Minutes are enough: automated scanners watch new public commits.
- 03 Delete the file from the working tree, add it to .gitignore, and commit that.
- 04 Rewrite history only if you have a reason to, and know it does not fix the leak on its own: forks and existing clones keep the old commits either way.
- 05 Roll the replacement credential out everywhere using the checklist below, then prove the old one is dead by making a real request with it.
The rollout in step five is the section directly below. Do not stop at step three: a deleted file with a live key is the version of this that gets someone breached.
Rotate a credential without breaking every environment
Rotation touches the credential issuer, every deployment scope, and every process or build that consumes it.
- 01 Identify all consumers by code search, host/project inventory, and provider access logs where available.
- 02 Create a second credential at the provider when overlapping keys are supported.
- 03 Set the new value in development, preview, staging, production, workers, scheduled jobs, and any separate project that uses it.
- 04 Rebuild public build-time consumers and redeploy or restart server runtime consumers.
- 05 Exercise the real integration in every required environment and inspect provider-side success or failure logs.
- 06 Revoke the old credential, then confirm old deployment URLs and forgotten workers cannot still use it.
If the provider allows only one live credential, plan a maintenance window or its documented no-overlap procedure. Copying the same old value to a new dashboard is configuration migration, not rotation.
Diagnose “works locally, fails in production”
Use a narrow sequence that preserves the secret:
- 01 Find the exact framework lookup in code and classify it as client, server, build-time, or runtime.
- 02 Confirm the name and case match exactly; Linux hosts are case-sensitive even when a local Windows environment is not.
- 03 Check the target scope: preview and production may have separate values.
- 04 Confirm a new build, deployment, staged change, or process restart happened after the dashboard update.
- 05 Validate type parsing and URL format rather than treating any non-empty string as valid.
- 06 Check whether the code runs in a different service, function, worker, or build job from the scope you edited.
- 07 Use a safe health indicator that reports configured or missing without returning the value.
The sibling guide on why an app works locally but not in production covers the broader deployment causes. Use that diagnostic tree when the variable checks above pass.
Common questions about environment variables
What is a .env file?
A .env file is a plain text file in your project root that lists KEY=value pairs, one per line, which a loader such as dotenv, Vite or Next.js reads into your app’s environment on your own machine. It feeds your local runs only: your host’s builds and servers read values from the hosting dashboard instead, so a value that exists only in .env never reaches the live app.
What does .env stand for?
env is short for environment. The file has no name before the dot, only the extension, which is why macOS and Linux hide it by default. Nothing about the name is special to the operating system: it is a convention that loaders such as dotenv, Vite, and Next.js agree to look for in your project root.
How do I create a .env file?
Make a plain text file named .env in your project root. In an editor, create a new file and type .env as the whole name; from a terminal, touch .env does it on macOS and Linux, and New-Item .env -ItemType File does it in PowerShell. Then add one KEY=value per line, add .env to .gitignore, and restart your dev server so the loader reads the new file.
Two follow-ups catch people out. Finding it again: macOS and Linux hide any name starting with a dot, so ls -a is how you list it, and you open it in a code editor like any other text file. Sourcing it: source .env in bash or zsh sets those names in your current shell but does not pass them to programs you run afterwards, because the lines carry no export. Running set -a first fixes that, since bash then gives every variable it creates or modifies the export attribute.
In what file are the environment variables usually stored?
None, strictly speaking. Environment variables live in the memory of a running process, and a file is only one way to get them there. Locally that file is usually .env in the project root. On a host there is often no file at all, because the platform injects the values into the process when it starts.
What is $env in PowerShell?
$Env: is PowerShell’s environment provider. $Env:API_KEY reads a variable and $Env:API_KEY = "value" creates or changes one. Microsoft’s documentation notes that the change affects only the current session, so put the line in your PowerShell profile if you want it in every window.
What is the difference between a secret and an environment variable?
A secret is sensitive configuration. Platforms can inject it into the process environment, expose it through a runtime binding, or require a secret API such as Base44’s secrets.get(). The storage label does not tell you how application code reads it.
Should .env always be in .gitignore?
Secret-bearing local files should not be committed. Next.js templates and Vite’s *.local convention follow that rule. Lovable’s older Vite projects are a documented exception for a committed .env containing browser-safe VITE_* build values. Never place a private key in that public file.
Does a hosting dashboard keep every variable secret?
No. The dashboard can protect storage and access to a value, but a framework can still inline a public-prefixed variable into browser JavaScript. Server code can also disclose values through logs or responses. Exposure depends on where the code uses the variable.
Do I need to restart after changing an environment variable?
Usually some apply action is required. Vite local development needs a restart after .env changes. Static or public-prefixed values need a rebuild. Vercel and Netlify changes apply to new deployments. Railway creates staged changes that must be deployed. A long-running server process needs the host’s restart or redeploy action.
Why is my preview deployment missing a value that production has?
Many hosts separate Preview and Production scopes. Add an appropriate non-production credential or value to Preview, deploy again, and verify that the preview cannot reach production data unless that access is intentional.
Are environment variables always strings?
At the Node process boundary and in Vite’s import.meta.env, custom values are strings. Parse and validate booleans, numbers, URLs, enums, and JSON before use. Framework schema tools such as astro:env can perform conversion and validation for you.
Built it with AI. Can’t get the last part right?
That’s the normal state of an AI-built app, and it’s fixable. I trace what the app actually does, explain what needs changing, and build it if you want me to.
Talk about your app →
Free 20-minute video call with me.