Bulletproof Your Node.js Backend: Manage Environment Variables with Confidence
vibhanshu pandey
Posted on April 15, 2023
Pretext
Managing environment variables is a crucial aspect of building robust and secure Node.js applications. These variables contain sensitive information such as API keys, database credentials, and other configuration details that your application needs to function properly. However, managing environment variables can quickly become a headache, especially when dealing with multiple environments, team members, and deployment scenarios.
One of the most significant pain points of environment variable management is the lack of type safety. Without type safety, it's easy to introduce bugs and security vulnerabilities, as developers may accidentally use the wrong variable or misspell its name. Additionally, environment variables can be difficult to keep track of, as they're often scattered across different files and deployment environments.
Fortunately, there are solutions to these problems. In this blog post, we'll explore how to manage environment variables in a type-safe manner using TypeScript. We'll look at why type safety is essential, how it can help prevent bugs and security issues, and best practices for implementing it in your Node.js backend. By the end of this post, you'll have a better understanding of how to manage your environment variables with confidence and peace of mind.
The Problem
Using dotenv for environment variable management doesn't allow breaking up the environment variables into different files like .env
, .env.development
, and .env.production
, among others. Additionally, it doesn't allow for variable expansion. For instance, when using dotenv, the following code will not work:
APP_NAME="My app name"
APP_DESCRIPTION="$APP_NAME is awesome"
Now, I expect APP_DESCRIPTION
to be "My app name is awesome"
but it won't be because dotenv doesn't perform variable expansion by itself, instead I'd have to use another library dotenv-expand to achieve this.
Also, I like using typescript and using process.env
doesn't really give me any clue about my environment variables.
This approach works but has following drawbacks:
- No IDE support for
process.env.APP_NAME
- No auto suggestion for
process.env.*
variables. - No Type checking is available for
process.env.APP_NAME
, and writing custom type is a hassle, because every time you need to add or remove an environment variables you'd have to update it's types as well. - No validation is performed on the values of these environment variables.
Typically, you should structure your .env
files like the following:
.env
.env.development
.env.test
.env.production
Optionally:
.env.development.local
.env.test.local
where .env
contains those environment variables that are common across all environments like:
APP_NAME
PORT
-
TZ
etc.
And environment specific file like .env.development
should contain environment specific variables like:
NODE_ENV
DATABASE_URL
-
SECRET
etc.
The Solution
Now, to eliminate all the drawback mentioned above, like type safety, variable expansion, and validation. Follow the solution below
Now obviously we'll still be using the file structure described above to keep our environment variables split among environment specific files.
This can be achieved with dotenv-cli, a new fast-growing npm package that can be used to eliminate the need of dotenv, dotenv-expand, and our custom code for loading multiple .env.*
files.
Syntax
npm i -D dotenv-cli
update your package.json
file scripts to be:
"dev": "dotenv -c development -- <your command goes here ex: ts-node src/index.ts>",
"build": "tsc",
"start": "dotenv -c production -- node dist/index.js",
What we're doing in dev
script is using dotenv-cli with -c flag to load:
.env
.env.development
-
.env.development.local
(if present)
In start
script we're loading:
.env
.env.production
TIP: Don't use .env.production.local
file.
So, This solves our multi-environment multi-file problem, and reduced our dependency from 2 package to one, and eliminate any custom code writing for loading .env
files.
Now, How can we solve our IDE support, Type checking, and validation problem.
For that we'll use envalid, a fast growing npm package to validate and provide types for our environment variables.
To get started:
npm i envalid
And yes we need it as our production dependency.
Now, we'll create a file right beside our index.ts
file named env.ts
, with the following content:
import { cleanEnv, str, email, json, port } from 'envalid'
// INFO: using $env to differentiate it from process.env
export const $env = cleanEnv(process.env, {
PORT: port(),
API_KEY: str(),
ADMIN_EMAIL: email({ default: 'admin@example.com' }),
EMAIL_CONFIG_JSON: json({ desc: 'Additional email parameters' }),
NODE_ENV: str({ choices: ['development', 'test', 'production', 'staging']}),
})
$env.isProduction // true if NODE_ENV === 'production'
$env.isTest // true if NODE_ENV === 'test'
$env.isDev // true if NODE_ENV === 'development'
And use $env
instead of process.env
.
By using $env
we'll get:
- Auto-completion ex:
$env.
should list all possible values in your IDE. - Type checking ex:
$env.PORT
will be of number type. - IDE support like: Reference count, Jump to code, show definition etc.
- Validation provided by envalid, as well as custom validation logic that we can combine with envalid, to really fine tune according to our requirement.
-
BONUS by using
env.ts
file we eliminate the requirement for a.env.example
file that a lot of us use to keep track of all the environment variables names that is used all across the project.
And there you have it
To learn more visit:
Feel free to comment down below, and let me know what you think.
Posted on April 15, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.