UPDATE: Since the release of NodeJS 20.6.0, we can directly load
.env
files by using the--env-file
command line option:node --env-file=.env index.js
, which makes this article a bit less relevant.
Preface
In the ever-changing world of NodeJS development, the choice of tools can profoundly impact a developer’s journey. Among these tools, the dotenv
package has long been a trusted partner, enabling developers to load environment variables from .env
files into process.env
.
However, despite its versatility and widespread use, the standard practices in utilizing dotenv
often leave much to be desired. This article will explore how refining our usage patterns of dotenv
can result in simpler and more robust code.
Introduction
Consider the following snippets, showing the most common way of using dotenv
:
require('dotenv').config(/* options */) // For CommonJS
// code depending on environment variables
import 'dotenv/config' // For ESM-compatible code
// code depending on environment variables
The ESM case stands out because it doesn’t initiate a function call and instead relies on side effects during import. In their documentation, they try to justify it by pointing fingers to the “unintuitive” behavior of ESM imports. I beg to differ, the problem is not with ESM, but with the usage patterns they propose in their documentation.
- Their proposed examples encourage defining module-scoped variables that are later exported, which is not ideal, as it makes it difficult to control the execution flow and to test modules in isolation.
dotenv
allows us to avoid performing implicit global side effects. However, its default behavior promotes these side effects, especially in the “ESM” case.
If we take care of implementing proper encapsulation and avoiding global or module-scoped variable declarations, then we can write instead (for the ESM case):
import { config } from 'dotenv'
config(/* options */)
// code depending on environment variables
This is more verbose, but also more explicit, gives us more control (as we can pass parameters to the config
call) and makes it easier to test our code in isolation.
But I went ahead of myself, why do we care about extra flexibility if we are only loading .env
files? Well, we might want to:
- Load other files instead, for example, something like
.env.test
,.env.local
, or.env.ci
. - Decide whether to override existing environment variables or not when loading the
.env
file. - Load the defined variables not into
process.env
, but into another object of our choice.
Having said that, I don’t think that we should be worrying about these details in our application code. This logic belongs elsewhere.
Removing clutter
You see, one question that comes to my mind is why are we using dotenv
for production code in the first place? We are certainly not going to rely on .env
files up there in the cloud, are we?
Given that we (hopefully) want to use dotenv
for development-only purposes, we can apply these steps:
- Move that dependency to the
devDependencies
section of ourpackage.json
file. This has the benefit of reducing the size of our production bundle (by 71.6 kB) and the complexity of our application logic. - Shift the usage of
dotenv
to the preloading phase of our application, this way we don’t need to import it inside our application, and we’ll use it only in our development scripts. This has the benefit that we don’t need to worry about the order of execution of application code depending on environment variables.
node -r dotenv/config ./src/index.js # Dev environment
node ./src/index.js # Production environment
If we need to parametrize the behavior of dotenv
, we can set DOTENV_CONFIG_<OPTION>
environment variables, for example:
DOTENV_CONFIG_PATH="./.env.test" node -r dotenv/config ./src/tests.js
or if we care about cross-platform compatibility (i.e. Windows support), we can use cross-env (which I also recommend to install as a dev dependency):
cross-env DOTENV_CONFIG_PATH="./.env.test" node -r dotenv/config ./src/tests.js
Another way to look at this is that we are moving the responsibility of loading environment variables from our application to the environment itself. The complexity is not gone, but it is now in a place where it is easier to manage.
Final tweaks
There are many “plugin” packages for dotenv
that extend its functionality, one of the most interesting ones is dotenv-expand
, which allows us to “expand” environment variables in our .env
files.
For example, if we have the following .env
file, dotenv-expand
will expand the HOST
variable into development.example.com
:
NODE_ENV=development
HOST=${NODE_ENV}.example.com
The problem is that we cannot easily use dotenv-expand
in the preloading phase, but luckily for us we still have options. dotenv-cli
is a CLI wrapper around dotenv
and dotenv-expand
that allows us to do so:
dotenv -e ./.env.test node ./src/tests.js
This one is my personal favorite, as it enables us to use the full power of dotenv
and dotenv-expand
in our development scripts in a much less verbose way, while still keeping the responsibility of loading environment variables outside of our application.
Conclusion
This article was focused on dotenv
, but if there are any takeaways I’d like to leave you with, they are these:
- Be critical of the tools you use, and don’t be afraid to challenge their most common usage patterns.
- When possible, try to minimise the amount of dependencies in your production code.
- Try to keep your application logic as simple as possible, don’t pollute it to handle use cases that only apply to development or testing environments.
Thank you for reading me!