DEV Community

Binary Journal
Binary Journal

Posted on

Environment Variables the Safe Way

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,
};
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Then in config:

module.exports = {
  debug: bool(process.env.DEBUG || 'false'),
  maxRetries: int(process.env.MAX_RETRIES || '3'),
};
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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(),
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (0)