Dotenv is dead

François Best • 2 October 2023 • 3 min read

Today I learned from Liran Tal that Node.js 20.6.0 brings support for loading environment variable files, just like the popular dotenv package does:

node --env-file=.env main.mjs

You can even load multiple files (since Node.js 20.7.0):

node --env-file=.env --env-file=.env.local main.mjs

This post could have ended here, but I’ll take this opportunity to pass the overtly click-baity title and talk about how I manage environment variables in my Node.js projects.

There are several issues with reading directly from process.env:

  1. It’s not type safe
  2. It’s not validated
  3. It’s not immutable

Type safety & validation

I like to use Zod to parse and validate outside data in my applications, and environment variables fall perfectly in that category.

I define a schema for the expected configuration, and run in on process.env:

env.ts
import { z } from 'zod'
 
const envSchema = z.object({
  // See https://cjihrig.com/node_env_considered_harmful
  NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),
 
  // External resource URIs
  POSTGRESQL_URL: z.string().url(),
  REDIS_URL: z.string().url(),
 
  // Secrets
  API_KEY: z.string().regex(/^[\da-f]{64}$/i),
 
  // Booleans
  DEBUG: z
    .string()
    .transform(value =>
      ['true', 'yes', '1', 'on'].includes(value.toLowerCase())
    )
    .default('false')
})
 
export const env = envSchema.parse(process.env)

More examples here.

We can now import this env object anywhere in our application.

Better error messages

When some environment variables are missing or invalid, Zod will throw an error on parsing which may not look very good for humans:

We can easily fix this by obtaining the list of errors and formatting it nicely:

env.ts
// ...
 
const parsed = envSchema.safeParse(process.env)
 
if (!parsed.success) {
  console.error(
    `Missing or invalid environment variable${
      parsed.error.errors.length > 1 ? 's' : ''
    }:
${parsed.error.errors
  .map(error => `  ${error.path}: ${error.message}`)
  .join('\n')}
`
  )
  process.exit(1)
}
 
export const env = parsed.data

Now that’s better:

Missing or invalid environment variables:
  NODE_ENV: Required

Immutability

One issue with process.env is that it’s mutable, and so is the env object we just created. Let’s fix this by freezing it: 🥶

env.ts
export const env = Object.freeze(parsed.data)

Security

If some of the environment variables are secrets, we can go further and delete them from the process.env global object, so they are only accessible from our parsed env object:

env.ts
const secretEnvs: Array<keyof typeof envSchema.shape> = [
  'POSTGRESQL_URL',
  'REDIS_URL',
  'SIGNATURE_PRIVATE_KEY'
]
 
for (const secretEnv of secretEnvs) {
  delete process.env[secretEnv]
}

François Best

Freelance developer & founder