When you ask ChatGPT to "set up a database connection," it gives you code that points to localhost. When you deploy that code to Vercel or Cloudflare, it crashes because there is no localhost on a cloud server. AI tools have zero context about your deployment target, they write for the happy path on your laptop and nothing else. Structuring your configuration per-environment is how you prevent those deployment surprises.
The three environments you will encounter
Most projects run through at least three stages before code reaches real users:
| Environment | Purpose | Typical settings |
|---|---|---|
| Development | Your local machine, fast feedback, verbose logging, disposable data | DEBUG=true, LOG_LEVEL=debug, localhost URLs |
| Staging | Mirrors production infrastructure, final testing before launch | DEBUG=false, LOG_LEVEL=info, staging URLs |
| Production | Live traffic, real data, real consequences | DEBUG=false, LOG_LEVEL=warn, production URLs |
# Development .env
NODE_ENV=development
DEBUG=true
LOG_LEVEL=debug
DATABASE_URL=postgresql://localhost:5432/devdb
API_URL=http://localhost:3001
# Production (set in Vercel/Cloudflare dashboard, not in a file)
NODE_ENV=production
DEBUG=false
LOG_LEVEL=warn
DATABASE_URL=postgresql://prod-db.internal:5432/production
API_URL=https://api.myapp.comDATABASE_URL=postgresql://localhost:5432/mydb in config files meant for production. Always verify that production config is set through your hosting platform's dashboard, not hardcoded in any file that gets deployed.Reading NODE_ENV in your code
NODE_ENV is the universally recognized variable for environment detection. Libraries like Express, React, and Next.js use it internally, and you should too:
const env = process.env.NODE_ENV || 'development';
const isDev = env === 'development';
const isProd = env === 'production';
if (isDev) {
console.log('[DEBUG] Request body:', JSON.stringify(req.body));
}
// AI mistake: logging sensitive data in all environments
// ChatGPT often generates this:
console.log('Database URL:', process.env.DATABASE_URL); // Leaks in prod logs!
// Correct: only log in development
if (isDev) {
console.log('Database URL:', process.env.DATABASE_URL);
}The config moduleWhat is module?A self-contained file of code with its own scope that explicitly exports values for other files to import, preventing name collisions. pattern
Scattering process.env reads throughout your codebase makes them hard to audit and easy to forget. A single config.js file that owns all environment logic is far better:
// config.js - single source of truth for all configuration
require('dotenv').config();
const env = process.env.NODE_ENV || 'development';
const configs = {
development: {
port: 3000,
database: process.env.DATABASE_URL || 'postgresql://localhost:5432/devdb',
logLevel: 'debug',
isDev: true,
corsOrigin: 'http://localhost:4321',
},
staging: {
port: Number(process.env.PORT) || 8080,
database: process.env.DATABASE_URL,
logLevel: 'info',
isDev: false,
corsOrigin: 'https://staging.myapp.com',
},
production: {
port: Number(process.env.PORT) || 8080,
database: process.env.DATABASE_URL,
logLevel: 'warn',
isDev: false,
corsOrigin: 'https://myapp.com',
},
};
module.exports = configs[env] || configs.development;Now the restWhat is rest?An architectural style for web APIs where URLs represent resources (nouns) and HTTP methods (GET, POST, PUT, DELETE) represent actions on those resources. of your code imports clean config values instead of raw process.env strings:
const config = require('./config');
app.listen(config.port, () => {
console.log(`Running on port ${config.port} [${process.env.NODE_ENV}]`);
});Feature flags
Feature flags let you merge code that is not fully ready, then turn it on later without a new deployment:
# .env
FEATURE_NEW_DASHBOARD=true
FEATURE_BETA_API=false
FEATURE_DARK_MODE=true// Boolean check - remember, process.env values are strings
if (process.env.FEATURE_NEW_DASHBOARD === 'true') {
renderNewDashboard();
} else {
renderOldDashboard();
}| Use case | How flags help |
|---|---|
| Gradual rollout | Enable for 10% of users, monitor, then expand |
| A/B testing | Show two variants and measure which performs better |
| Instant rollback | Set flag to false, no redeploy needed |
| Internal-only features | Enable in staging, disable in production |
| Kill switch | Disable a broken feature instantly |
Config validation at startup
The most frustrating production bugs are caused by a missing env var. The app starts fine, then explodes 30 seconds later when it tries to use undefined as a database URL. Fail fast instead:
// validate-env.js - run this before anything else
function validateEnv(requiredKeys) {
const missing = requiredKeys.filter(key => !process.env[key]);
if (missing.length > 0) {
console.error('Missing required environment variables:');
missing.forEach(key => console.error(` - ${key}`));
console.error('\nCheck your .env file or platform environment settings.');
process.exit(1);
}
}
// In production, these MUST be set
if (process.env.NODE_ENV === 'production') {
validateEnv(['DATABASE_URL', 'JWT_SECRET', 'STRIPE_SECRET_KEY']);
}Deploying to cloud platforms
Every major platform has its own way to set production environment variables. You never commitWhat is commit?A permanent snapshot of your staged changes saved in Git's history, identified by a unique hash and accompanied by a message describing what changed. production secrets, you set them in the platform's dashboard and they are injected at runtimeWhat is runtime?The environment that runs your code after it's written. Some languages need a runtime installed on the machine; others (like Go) bake it into the binary..
# Vercel - add via CLI or dashboard
vercel env add DATABASE_URL production
# Prompts you to enter the value securely
# Cloudflare Workers - secrets are encrypted at rest
wrangler secret put STRIPE_SECRET_KEY
# Value never appears in shell history
# Railway - set in the dashboard Variables tab
# Railway auto-injects DATABASE_URL for provisioned databases
# Docker - pass at runtime
docker run -e NODE_ENV=production -e DATABASE_URL=postgres://... myapp| Platform | How to set vars | Where they live |
|---|---|---|
| Vercel | Dashboard or vercel env add | Encrypted, per-environment |
| Cloudflare Workers | wrangler secret put or dashboard | Encrypted secrets store |
| Railway | Dashboard Variables tab | Auto-injected at runtime |
| Render | Dashboard Environment tab | Encrypted, per-service |
| Docker | -e flag or --env-file | Passed at container start |
Quick reference
| Concept | Best practice |
|---|---|
| Environment detection | process.env.NODE_ENV \|\| 'development' |
| Config structure | Single config.js module, not scattered process.env reads |
| Feature flags | String comparison: === 'true', delete when fully rolled out |
| Startup validation | Check required vars exist, process.exit(1) if missing |
| Platform secrets | Set in dashboard or CLI, never in committed files |
| AI-generated config | Always verify URLs and credentials are not hardcoded for localhost |