Environment Variables the Safe Way
Environment variables are the standard way to configure applications without hardcoding secrets or environment-specific details. But they're easy to misuse. I've seen API keys committed to repos, configs that crash when a variable is missing, and defaults that silently override production settings. Here's how I handle them safely.
Never Commit Secrets
The most important rule: never put real secrets in your code or commit them to version control. That includes .env files. Add .env to your .gitignore immediately. If you're using a framework like Laravel or a tool like Vite, the default .env.example is your friend. Commit that, but never the real one.
For local development, you can generate a .env from the example and fill in your own values. For production, set variables through your hosting provider's dashboard or a secrets manager like AWS Secrets Manager or HashiCorp Vault.
Read Variables Explicitly
Don't access process.env directly all over your codebase. Instead, centralize your configuration. Create a config.js (or config.ts) that reads and validates all the variables you need.
// config.js
const required = ['DATABASE_URL', 'JWT_SECRET', 'PORT'];
const missing = required.filter(key => !process.env[key]);
if (missing.length) {
throw new Error(`Missing required environment variables: ${missing.join(', ')}`);
}
module.exports = {
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
port: parseInt(process.env.PORT, 10) || 3000,
};
Now your app imports config and uses config.port. This has several benefits:
- Fail fast: if a required variable is missing, the app crashes at startup, not later when you try to use it.
- Type safety: you can parse and validate values once.
- Easy to mock in tests.
Use Defaults Carefully
Defaults are convenient, but they can hide problems. For example, if you default PORT to 3000 in production, you might accidentally run on the wrong port without noticing. I prefer to have no defaults for critical variables, and only provide defaults for non-critical ones like log levels or feature flags.
In the config above, I used || 3000 for port. That's fine for development, but consider whether you want that in production. If you're not sure, make it required.
Parse and Validate Types
Environment variables are always strings. If you need a number, boolean, or array, parse them explicitly. I've seen bugs from if (process.env.DEBUG === 'true') vs if (process.env.DEBUG) where the latter is true even when the variable is 'false'. Use a small helper:
function bool(value) {
return value === 'true' || value === '1';
}
function int(value) {
const n = parseInt(value, 10);
if (isNaN(n)) throw new Error(`Invalid integer: ${value}`);
return n;
}
Then in config:
module.exports = {
debug: bool(process.env.DEBUG || 'false'),
maxRetries: int(process.env.MAX_RETRIES || '3'),
};
Avoid Naming Collisions
Prefix your variables with your app name, like MYAPP_DB_HOST instead of just DB_HOST. This prevents conflicts when multiple apps run in the same shell or CI environment. It also makes it clear which variables belong to your app.
Don't Log Secrets
It's tempting to log the config at startup for debugging. Don't log secrets. If you must, mask them:
console.log('Config loaded', {
databaseUrl: mask(config.databaseUrl),
jwtSecret: mask(config.jwtSecret),
});
function mask(str) {
if (!str) return str;
return str.slice(0, 4) + '...' + str.slice(-4);
}
This shows enough to verify it's set, but not enough to leak.
Use a Library for Complex Config
If you need nested config, defaults, and validation, consider a library like dotenv for loading .env files, and envalid or convict for validation. These tools handle parsing, required checks, and error messages for you.
// With envalid
const { cleanEnv, str, port } = require('envalid');
const env = cleanEnv(process.env, {
PORT: port({ default: 3000 }),
DATABASE_URL: str(),
JWT_SECRET: str(),
});
It throws a clear error listing all missing variables, which is much nicer than debugging a undefined later.
Keep .env Out of Docker Images
If you use Docker, don't bake environment variables into the image. That's a security risk and makes the image less portable. Instead, pass them at runtime with -e or use a .env file with --env-file. In docker-compose, use environment or env_file.
CI/CD Considerations
In CI, set variables in the pipeline settings, not in the code. Most CI systems have a way to store secrets encrypted. Use those. In GitHub Actions, you can use secrets in your workflow file:
- name: Run tests
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: npm test
Final Thought
The goal is to make configuration explicit, fail fast, and keep secrets out of code. Start with a simple config module, add validation, and never commit real values. Your future self will thank you when you don't wake up to a security breach or a mysterious production bug.
Happy coding!
United States
NORTH AMERICA

