Environment Variables Not Working in Production: The Fix
Why.env works locally but fails after deploy: 4 root causes, build-time inlining, framework prefixes, and the debugging checklist to fix it.
- Environment variables must be set in your platform's dashboard (Vercel, Netlify, Railway, Render, Ship), not in.env files -.env files are for local development only and should never be committed to git
- Build-time variables (VITE_, NEXT_PUBLIC_, REACT_APP_, EXPO_PUBLIC_) are embedded at build time and require a rebuild to take effect
- Most production failures come from forgetting to set variables in the dashboard or not redeploying after setting them
- Always rebuild and redeploy after changing environment variables, especially for build-time prefixed variables
Environment variables break in production because you're confusing three separate things: your.env file (local only), your deployment platform's dashboard (the real source of truth), and where your code looks for these values at runtime. Your app worked fine locally because dotenv loaded the file. After deploy, that file doesn't exist on your server, and if you didn't set the variables in your platform's dashboard, your code finds nothing.
Why Environment Variables Fail After Deployment (4 Root Causes)
Your app worked locally but broke after deploy. One of these four things happened.
1. The.gitignore mistake. You created a.env file, added it to.gitignore (correctly), and deployed your code. Your version control system never sent.env to production. Your deployment platform has no idea what values should be there. Result: process.env.API_KEY is undefined on the server. This is the most common failure. You have the code, the platform is running it, but the environment doesn't exist.
2. Build-time inlining trap. Vite, Create React App, Next.js, and Expo all have a feature: certain environment variables get baked into your bundle at build time. If you use VITE_SECRET_KEY, it gets embedded in your JavaScript before it ships to the server. If you change the value after the bundle is built, the old value stays in the code. Your app loads the old secret, not the new one. You redeploy the code without rebuilding, and nothing changes. The problem is invisible: logs show the new variable in the dashboard, but the running code has the old value.
3. Platform dashboard mismatch. You set variables in your hosting platform's dashboard (Vercel, Netlify, Railway, Render, or Ship), but you didn't restart or redeploy your app. The variables exist in the dashboard, but your running process never loaded them. The app is still using the old environment, from when it started yesterday. This is the second-most common mistake: the variable is set, but the running process is stale.
4. Variables not reloaded after update. You deployed your app once. It loaded environment variables at startup. Now you change a variable in your dashboard and redeploy. The app starts fresh, loads the new variables, and works. But if you only changed the variable without redeploying, the running process still has the old values in memory. Restart or redeploy to refresh. A dashboard change alone does not notify a running process to reload.
Local vs Production: Where Environment Variables Live
Your.env file is a development tool. It only exists on your machine. When you run npm start locally, a package called dotenv (or built into your framework) reads.env and injects those key-value pairs into process.env. Your code accesses them and works. The file lives on your disk, the package opens it, and the values populate the environment.
On production,.env doesn't exist. You don't commit it. You don't copy it to the server. So where do your environment variables come from? Your hosting platform: Vercel, Netlify, Railway, Render, or Ship all have dashboards where you paste your secrets. When your app starts on their servers, the platform injects those values into the running process, the same way dotenv did locally. Your code accesses process.env.API_KEY and gets the production value. The platform is doing what dotenv did: reading values and injecting them into process.env.
The mental model shift: Locally, dotenv pulls values from a file. In production, your platform pulls values from a database and injects them as the app starts. Your code doesn't care. It just reads process.env.
This is why your.env file never goes to production. It would be a security disaster: your secrets committed to git, visible in version control, shipped to the internet. Instead,.env.example goes to git. It shows which variables you need, with fake or blank values. Developers clone the repo, copy.env.example to.env, fill in their local values, and run. For production, you use your platform's dashboard. Vercel and Netlify both store secrets encrypted. Ship does the same. Your real API keys live only in the dashboard, not in your repo. When you need a deeper understanding of secrets management beyond just deployment, read our guide on how to handle secrets properly in production.
How Build-Time Inlining Works (and Breaks)
Some environment variables get baked into your application bundle at build time, not injected at runtime. This is a deliberate feature and the source of confusion.
Why? Some JavaScript cannot access environment variables at runtime. If you build a static site or a mobile app that runs in a browser, there's no "runtime process" to inject variables into. The bundle runs on the user's device or in their browser. So the build system has to embed the values before it ships.
Vite: Any variable prefixed with VITE_ gets baked in. If you set VITE_API_URL to your actual API endpoint in.env during local development, Vite inlines that URL into your bundle. Your code references import.meta.env.VITE_API_URL and gets the value directly from the JavaScript. On production, if you set VITE_API_URL in your platform dashboard but don't rebuild, the bundle still has the old URL. You must rebuild and redeploy. This is the Vite gotcha: the variable exists in your platform's dashboard, but the old value is in the deployed bundle. For the full troubleshooting walkthrough specific to Vite, see our guide on Vite environment variables undefined in production builds.
Next.js: Variables prefixed with NEXT_PUBLIC_ get embedded in the client bundle. This is intentional: they're safe to be public (visible in browser DevTools). Server-side Next.js variables (no prefix) stay private and are injected at runtime. The mix is powerful but confusing. If you need a secret on the client, you cannot use NEXT_PUBLIC_. If you need it server-side only, omit the prefix. Read the Next.js documentation on environment variables for the full list. For real-world debugging on Vercel, check out our Next.js and Vercel troubleshooting guide.
Create React App (CRA): Variables prefixed with REACT_APP_ get baked in. Same principle as Vite.
Expo (React Native): Variables prefixed with EXPO_PUBLIC_ get inlined. And there's a gotcha: if you use optional chaining on an inlined variable, the build system cannot inline it correctly. Avoid export const apiUrl = process.env.EXPO_PUBLIC_URL?.trim() in a production build. This breaks the inlining because Expo's build system cannot statically analyze optional chaining.
The rule: If it's prefixed with a special marker (VITE_, NEXT_PUBLIC_, REACT_APP_, EXPO_PUBLIC_) and references a build-time variable, it gets embedded. Changing the value after build doesn't work. You must rebuild and redeploy.
The Environment Variable Checklist for Deployment
Before you deploy, check these in order:
-
Committed.env.example, not.env. Your.env file should be in.gitignore. Your.env.example should be in git, listing all required variables with empty or fake values. This tells the next developer what to set up locally. Check your.gitignore file first.
-
All production variables are in your platform's dashboard. Open Vercel, Netlify, Railway, Render, or Ship. Go to the environment variables section. Paste every secret you need. Use the exact same key names (API_KEY, DATABASE_URL, etc.). Do not use.env file uploads; use the dashboard UI. The dashboard is the source of truth.
-
Build-time vs runtime: Prefix correctly. If you're using Vite, prefix with VITE_. If Next.js, use NEXT_PUBLIC_ for client and no prefix for server secrets. If Expo, use EXPO_PUBLIC_. If plain Node.js, no prefix is needed; variables are injected at runtime. Know your framework's rules before deploy.
-
Special characters: Escape if needed. If your secret contains spaces or special characters (like a connection string with =, :, @), wrap it in quotes in the dashboard. Example:
DATABASE_URL="postgresql://user:pass@host:5432/db". Most platforms handle this, but check your platform's docs. -
Rebuild and redeploy after setting variables. Especially for build-time variables (VITE_, NEXT_PUBLIC_). The build system has to process them. A new deploy usually triggers a rebuild; confirm in your platform's logs. Don't just set the variable and hope.
-
Verify they made it to production. Temporarily log the variable in your production code:
console.log("API_KEY:", process.env.API_KEY). View the logs in your platform's dashboard. Delete the log line once you've confirmed. Do not leave secrets in logs.
How to Debug When It Still Doesn't Work
Follow these checks in order. Stop when you find the problem.
Check 1: Is the variable in your.env.example? If you forgot to document it, you might have skipped it in production too. Add it to.env.example as a placeholder. If it's there and you didn't set it in the dashboard, move to Check 2.
Check 2: Is it set in your platform's dashboard? Open Vercel, Netlify, Railway, Render, or Ship. Go to Settings > Environment Variables. Search for the variable name. If it's not there, add it. If it is there, copy the value and verify it's correct. Typos, trailing spaces, or wrong values break everything. Copy-paste from a trusted source, don't retype.
Check 3: Did you rebuild and redeploy after setting it? Setting a variable in the dashboard doesn't automatically restart a running app. You must redeploy. In Vercel, Railway, and Ship, a new deploy triggers a rebuild and restart. Click "Redeploy" in the platform. Watch the deploy log. If it shows errors, the deployment failed and the app is still running the old version.
Check 4: Is it the right variable name (typos, case sensitivity)? Environment variables are case-sensitive. API_KEY and api_key are different. Run a diff between your local.env and the dashboard. Copy-paste variable names to avoid typos. Even a single character difference breaks it.
Check 5: For build-time variables (VITE_, NEXT_PUBLIC_): Is it in the build output?
Vite, Next.js, and CRA embed these at build time. If the build skipped it, your code never sees it. To check: (a) Build locally: npm run build or yarn build. (b) Open the generated bundle file (dist/index.js or.next/static). Search for your variable value. If it's not there, it wasn't embedded. Check your variable name matches the prefix rule (VITE_, NEXT_PUBLIC_, etc.).
Check 6: Is the variable actually being used in code?
Typo in the code? Wrong import (require vs import)? Unused variable? Try a simple test: console.log(process.env.API_KEY) at the top of your main file. If it logs undefined, the variable isn't there. If it logs a value, it's there but maybe not being used right.
If you've checked all six and it still doesn't work, check the platform's deployment logs. They often show environment variable issues (e.g., "Variable not found" if the dashboard was empty). Most platforms also support SSH or log streaming; use that to watch the app start and see what variables it loaded.
Why Ship Handles This Automatically
With Ship, you set environment variables once in the dashboard, and they stay in sync with your app. There's no.env file dance, no build-time inlining confusion, no restart/redeploy guessing.
When you deploy to Ship, the platform injects all variables at runtime, as your app starts. You change a variable in the Ship dashboard, redeploy (one click), and the new value is live. You don't need to rebuild. You don't need to worry about prefixes. Variables are variables. Ship treats all variables the same: they're injected at runtime, not embedded in the bundle.
Ship also validates them before deploy. If a required variable is missing, the deploy fails with a clear error, not a runtime crash on production. You catch the mistake before your app is broken on production users.
Checklist: Never Break This Again
Use this one-page reference before every deploy:
-.env file is in.gitignore -.env.example is in git with all required keys (use placeholder values)
- All production secrets are set in your platform's dashboard (Vercel, Netlify, Railway, Render, or Ship)
- Variable names match exactly (case-sensitive, no typos)
- No spaces around the = in the dashboard (key=value, not key = value)
- For Vite, Next.js, CRA, or Expo: Prefix is correct (VITE_, NEXT_PUBLIC_, REACT_APP_, EXPO_PUBLIC_)
- Rebuild and redeploy after setting variables
- Verify in logs: console.log(process.env.VAR_NAME) shows the right value, then remove the log
- No secrets in git. Never.
- No secrets in browser DevTools. Use server-side variables or don't use variables for secrets at all (use /api/config).
Frequently Asked Questions
Why do environment variables work locally but fail in production? Your.env file is a local development tool. The dotenv package reads it and injects values into process.env at startup. On production servers, the.env file doesn't exist. Only your platform's dashboard has the values. The platform injects them the same way dotenv did locally. If you didn't set variables in the dashboard, your production code finds undefined values.
Do I need.env files in production? No. Never..env files are for local development. Production uses your platform's dashboard. A.env file on a production server is a security risk: it's either committed to git (exposing secrets) or manually copied (error-prone). Use dashboards: Vercel, Netlify, Railway, Render, Ship. They encrypt secrets and manage them safely.
What's the difference between build-time and runtime environment variables? Build-time variables are baked into your compiled or bundled code. Vite (VITE_), Next.js (NEXT_PUBLIC_), and CRA (REACT_APP_) embed these during build. Runtime variables are injected as the app starts. Most backend variables are runtime. Build-time variables cannot be changed after deploy without rebuilding. Runtime variables can be changed by redeploying with a new dashboard value.
How do I know if my environment variables made it to production?
Deploy a temporary console.log: console.log("API_KEY is:", process.env.API_KEY). View your app's logs in the platform dashboard. If it shows the right value, they made it. Remove the log line after confirming. For web apps, use your platform's log viewer (Vercel and Ship both have built-in logs). For background jobs, check the job logs.
Do I need to restart my app after setting a new environment variable? Yes. Restart or redeploy. Setting a variable in the dashboard doesn't notify a running process to reload. The process started with the old environment and has no way to know it changed. Redeploy (platform rebuilds and restarts) or manually restart (if you have access). Most developers just redeploy, which is simpler and guarantees a fresh start.
What if I have different variables for different environments (dev, staging, production)? Most platforms (Vercel, Netlify, Railway, Ship) support environment tags or separate project branches. Set dev variables for your dev deploy, production variables for production. Your code reads the same variable names; the platform injects the right values for each environment. Keep your code environment-agnostic. The platform handles the environment switching.
How do I debug environment variable issues in Node.js or Python?
The root cause is the same regardless of language. Environment variables must be injected at runtime or baked into your build. In Node.js, use process.env.VAR_NAME. In Python, use os.environ['VAR_NAME']. Set them in your platform's dashboard, never in.env files on production servers. For a deeper dive into production-grade secrets handling, read our guide on how to handle secrets properly in production.
The Bottom Line
Environment variables fail in production because you're sending the.env file to git or forgetting to set them in your platform's dashboard. Your local app works fine because dotenv loads the file. Production has no file, so you must set variables in the platform instead. Build-time variables (VITE_, NEXT_PUBLIC_) need a rebuild to take effect. Runtime variables just need a redeploy.
The checklist:.env in.gitignore,.env.example in git, all secrets in the platform's dashboard, correct prefixes for your framework, rebuild and redeploy after setting variables. Test by logging the variable in production and checking logs. If you use Ship managed hosting, this entire problem class is solved. The platform handles variable injection, validation, and versioning, so you never have to debug this again.