Adaptive Kind

Exploring the New Native env File Support in Node.js

Published on by

Back in 2011, The Twelve-Factor App laid out some fundamental principles about keeping configuration separate from code. It became the go-to guide for how we handle environment variables, influencing everything from Docker's ENV directives to how we structure modern applications.

Fast forward to September 2023, and Node.js v20.6.0 shipped with something developers had been asking for: native .env file support. No more reaching for external libraries for basic environment file loading.

Let's explore what this native functionality can do, how it's evolved over the past year, and when you might still want to consider alternatives.

Getting started with native .env files

Let's create an .env file. This file is a list of name value pairs for the environment variables we want to set. For example it might look like:

FOO=from-env
BAR=from-env

We can run a node process to inspect the environment variables that have been set.

node --env-file .env -e "console.log(process.env)"

This command prints out process.env. This is an object containing the environment variables for the process and returns all the environment variables from your shell, along with the environment variable from the env file that was injected. We can see that the environment variables from our .env file have been injected.

{
  USER: 'ian',
  LANG: 'en_GB.UTF-8',
  HOME: '/Users/ian',
  FOO: 'from-env',
  BAR: 'from-env',
  ...
}

Pretty straightforward, right? No external dependencies, no setup complexity. The environment variables from your .env file are automatically available in your Node.js process. Let's see what else this native support can handle.

Working with multiple environment files

Sometimes you need different configurations for different environments. Let's create a development-specific file called .env.dev:

BAR=from-dev

Multiple --env-file arguments can be referenced on the node command line.

node --env-file .env --env-file .env.dev -e "console.log(process.env)"

Notice how the last file wins when there are conflicts. This gives you a clean way to layer configurations - start with your base settings, then override with environment-specific values.

{
  FOO: 'from-env',
  BAR: 'from-dev',
  ...
}

How shell variables take precedence

Here's something useful to know: shell variables always win over .env file values. Whether you've set a variable globally with export FOO=from-shell or just for a single command by prefixing it with the assignment like FOO=value command, the shell value takes precedence.

FOO=from-mod node --env-file .env --env-file .env.dev -e "console.log(process.env)"
{
  FOO: 'from-mod',
  BAR: 'from-dev',
  ...
}

Controlling Node.js startup with environment files

The --env-file argument is processed during Node.js startup, before Node environment variables are handled. This means you can control Node.js itself through your .env files. Let's set up debugging as an example:

Create the file .env.debug with the arguments to wait for the debugger to connect and take control.

NODE_OPTIONS=--inspect-brk

Open up the node debugger in Chrome DevTools. You can open this from View -> Developer -> Developer Tools in the Chrome browser menu. Then click on the "Open dedicated DevTools for Node.js" icon.

Now run the command with this env file

node --env-file .env.debug -e "console.log(process.env.FOO)"

You should see the debugger attaching

Debugger listening on ws://127.0.0.1:9229/c84d172c-bf64-43b1-a525-b9d2d143ae5e
For help, see: https://nodejs.org/en/docs/inspector
Debugger attached.

Now you can debug your Node.js process directly in Chrome DevTools. This approach is particularly handy when you want to debug different environments without changing your command line every time.

Debugger from env-file

Support for multi-line environment variables

As of v20.12.0 (March 26, 2024), you can use multi-line values in your .env files (#51289). This is useful for things like JSON configuration or multi-line strings.

Create an .env file with a variable that spreads across multiple lines. Note that the value should be wrapped in double quotes to mark the start and the end of the value.

FOO="Value
over
many
lines"

And then run

node --env-file .env -e "console.log(process.env.FOO)"

This outputs the multi-line value exactly as expected:

Value
over
many
lines

Making environment files optional

Sometimes you want to use an environment file only if it exists, without breaking your application if it's missing. This is particularly useful when you have sensible defaults and only want to override them when needed.

Node.js v22.9.0 (September 17, 2024) added the --env-file-if-exists argument for exactly this purpose.

node --env-file-if-exists .env.opt -e "console.log(process.env.FOO || 'default')"

When the file doesn't exist, Node.js continues gracefully:

.env.opt not found. Continuing without it.
default

If we use the --env-file argument we see that the process exits with an error.

node --env-file .env.opt -e "console.log(process.env.FOO)" || echo "oops"
node: .env.opt: not found
oops

And when the file does exist, it works as expected:

> echo FOO=from-opt > .env.opt
> node --env-file-if-exists .env.opt -e "console.log(process.env.FOO || 'default')"
from-opt

Compatibility with shell-style export statements

Node.js thoughtfully ignores the export keyword in .env files, which helps with compatibility across different tooling.

With the .env file

export FOO=from-env

the FOO variable is loaded OK

> node --env-file .env -e "console.log(process.env.FOO)"
from-env

This is handy because tools like direnv use .envrc files that are actually shell scripts, requiring the export keyword. While Node.js can't execute these files as scripts, ignoring the export keyword means many of these files will work directly with Node.js too.

Loading environment files from your code

Starting with v20.12.0 (March 26, 2024), you can load environment files directly from your JavaScript code. To load the default .env file, just call process.loadEnvFile():

> node -e "process.loadEnvFile() ; console.log(process.env.FOO)"
from-env

Or we can load from a specific env file.

node -e "process.loadEnvFile('.env.dev') ; console.log(process.env.BAR)"
from-dev

This programmatic approach is useful when you need to load different environment files based on runtime conditions. Keep in mind that since this happens after Node.js has started, setting NODE_OPTIONS here won't affect the current process.

Parsing environment files without loading them

Sometimes you want to parse an environment file without actually setting the variables in your process. The util.parseEnv function lets you do exactly that - it takes the raw content of a .env file and returns an object with the parsed variables:

node -e "console.log(util.parseEnv('FOO=from-parse\n'))"
{ FOO: 'from-parse' }

This approach is particularly useful for:

  1. Scoped credentials - when you need sensitive values for specific integrations but don't want them globally accessible
  2. Validation - checking environment files before actually loading them
  3. Context-specific loading - different parts of your application need different environment configurations

When you might still need dotenv or dotenvx

While Node.js native support covers many common use cases, there are still scenarios where you might want the additional features that dotenv and dotenvx provide. These libraries offer capabilities that go beyond what's currently available natively:

  • Overriding environment variables from the shell. Node.js always takes the value from the shell if it has been set.
  • Variable expansion - reference and expand variables already set with ${FOO}.
  • Command expansion, e.g. $(whoami)
  • Encrypted .env files
  • Next.js env loading convention, such as the environment specific loading of env files following the naming convention of .env.$(NODE_ENV).

The dotenvx documentation has comprehensive examples if you want to explore these advanced features.

direnv

It is also worth mentioning the direnv command. This is an extension for shell and can be used for more than Node.js. It works by loading (and unloading) environment variables on a per-directory basis.

You'll need to install direnv locally and hook it into your shell.

Once you have it installed, you can create an .envrc in a directory

echo export FOO=from-direnv > .envrc

And when you change into that directory, or a child of that directory, you'll get a message from direnv asking whether you'd like to approve the content. You'll also be asked for this approval whenever the .envrc file changes, perhaps by you directly or via a git pull. This protection is required, since this .envrc is a script that is run and could have unintended side effects.

direnv: error /home/bob/project/my-env/.envrc is blocked. Run `direnv allow` to approve its content

When you allow the content with direnv allow, you'll get a message indicating that environment variables are loaded.

direnv: loading /home/bob/project/my-env/.envrc
direnv: export +FOO ~XPC_SERVICE_NAME

If you move out of the directory, e.g. cd .., you'll get a message indicating that the environment variables are unloaded.

direnv: unloading

Read the direnv docs for many examples and patterns of usage.

Choosing the right approach for your project

With all these options available, how do you decide what's best for your situation?

For command-line focused workflows

I've found direnv incredibly useful for command-line work. It automatically loads environment variables when you enter a directory and unloads them when you leave. This works with any command or runtime, not just Node.js, making it perfect if you work across multiple technologies or spend most of your time in the terminal.

For Node.js projects

The landscape has shifted significantly. For new Node.js projects, I'd now recommend starting with the native .env support. It handles most common scenarios without any dependencies:

  • Start with native support if you need basic environment file loading
  • Consider dotenv/dotenvx if you need variable expansion, encryption, or follow specific conventions like Next.js environment loading

What's changed

A year ago, I'd automatically reach for dotenv on new projects. Now, the native functionality covers enough use cases that many projects won't need the extra complexity. The ability to control Node.js startup options through .env files is particularly valuable for debugging and development workflows.

Looking ahead

As Node.js continues to evolve its native .env support, the gap between native and library-based solutions will likely narrow. For now, the native approach provides a solid foundation that you can build upon as your needs grow.

The key is to match your tooling to your actual requirements rather than adding complexity upfront. Start simple, then add features as you need them.