[logo] a small computer

Loading Multiple .env Files for Different Environments in NodeJS

Loading Multiple .env Files for Different Environments in NodeJS

Bradley Kofi
Published 06/12/2021.
2 minute read
logo of undefined

Table of Contents

Introduction

Using a .env file for keeping secrets (that you don’t want accidentally leaked to the world) is standard practice. However, figuring out a strategy for managing .env files between multiple environments on a single machine isn’t so obvious.

For example, on a local machine, it’s possible to have the development and test environments. The former is for secrets on the development server, and the other for secrets to be used during local tests respectively. The two environments will likely run on different databases, for instance - rocky_dev for development data and rocky_test for test data. (Due to the different way development and test data are handled, you probably want two different databases.) The question that arises at this point is - how can you provide the different environment variables for multiple environments on a single machine?

In this guide, we answer that question exactly, using NodeJS and dotenv to load multiple .env files into your into a project.

Multiple .env files with dotenv

The most natural solution is to have two different .env files - one for each environment. Let’s name the first .env.dev for the development environment and the other .env.test for the test environment.

In .env.dev, let’s add the NODE_ENV variable and the development database URL:

.env.dev
NODE_ENV=development DATABASE_URL=postgres://rambo:123456789@localhost/rocky_dev

And do the same for the .env.test file:

.env.test
NODE_ENV=test DATABASE_URL=postgres://rambo:123456789@localhost/rocky_test

To read these variables into our project through the process.env interface, we need to install and import the dotenv package. However, how will dotenv know which file to load the variables from?

One crucial fact comes to our rescue is that dotenv can be configured to look for environment variables in files other than .env (imagine that! 😲)

Taking advantage this, let’s write code to load the .env.dev file and check whether we’re in the development or test environment.

(Oh, and don’t forget about type safety 😉)

env.config.ts
import dotenv from 'dotenv'; import path from 'path'; type Environment = "development" | "test" class Env { // the file to be loaded in development environments private dotEnvDevelopment = '.env.dev'; constructor() { // configure dotenv to resolve a file named ".env.dev" // found in the currrent working directory dotenv.config({ path: path.resolve(process.cwd(), this.dotEnvDevelopment), }); } // Get a value from the .env.* file getEnvironmentVariable(variable: string): string{ return process.env[variable]; } // Get the current environment. Can be null. getEnvironment(): Environment | null { return (this.getEnvironmentVariable('NODE_ENV') as Environment); } isDevelopment(){ return this.getEnvironment() === 'development'; } isTest(){ return this.getEnvironment() === 'test'; } } const env = new Env(); export default env;

In use:

app.ts
import {env} from "./Env" const environment = env.getEnvironment(); const isDevelopment = env.isDevelopment(); const isTest = env.isTest(); // will always be false. We haven't set it up yet.

But we will also need to check for the following common environments: test, ci, staging, producion.

  • ci - Environment for running tests on a remote server.
  • staging - Environment for pre-production code.
  • production - Environment for user-facing code.

Exercise to the reader: figure out how we can check whether we are in the above environments.

Of course, isTest() will always be false since we never load the .env.test file.

To fix that, let’s finally create a .env file and add the NODE_ENV variable:

.env
NODE_ENV=development

And use that to let the dotenv package know what file to load.

env.config.ts
class Env { const dotEnvDefault = ".env"; const dotEnvTest = ".env.test"; const dotEnvDevelopment = ".env.development"; constructor() { this.init(); } init() { // first, ensure a .env file xists if (!fs.existsSync(this.dotEnvDefault)) { throw new Error( "Please add a .env file to the root directory with a NODE_ENV value" ); } // then configure dotenv with the configuration in the .env file dotenv.config({ path: path.resolve(process.cwd(), this.dotEnvDefault), }); // and get the environment specified const environment = this.getEnvironment(); // then load the name of the environment file const envFile = this.getEnvFile(environment); // and re-configure dotenv dotenv.config({ path: path.resolve(process.cwd(), envFile), }); } getEnvFile(environment: Environment): string { switch (environment) { case "development": return this.dotEnvDevelopment; case "test": return this.dotEnvTest; case "production": case "ci": default: return this.dotEnvDefault; } } getEnvironment(): Environment | null { return this.getEnvironmentVariable("NODE_ENV") as Environment; } }

Essentially, we load the dotenv package twice. First, to find out which environment we are currently in, then again to load the proper environment variable file. Of course, this means that in the production and ci environments, their respective files will be loaded twice 🤷‍♂

Additionally, it's always necessary to have a .env file. It acts as a 'controller' pointing to where the actual variables are contained.

That sums up the main point of this article.

Bonus Section: Checking for Required Environment Variables

As a bonus, we can also add functionality to check for required environment variables.

env.config.ts
import dotenv from 'dotenv'; import path from 'path'; import fs from 'fs'; type Environment = "development" | "test" | "production" | "ci" class Env { private dotEnvDevelopment = '.env.dev'; private dotEnvDefault = '.env'; private dotEnvTest = '.env.test'; private requiredKeys = [ "NODE_ENV" ] constructor() { this.init(); } init(){ if (!fs.existsSync(this.dotEnvDefault)) { throw new Error("Please add a ,env file to the root directory") } dotenv.config({ path: path.resolve(process.cwd(), this.dotEnvDefault), }); const environment = this.getEnvironment(); const envFile = this.getEnvFile(environment); // get a list of keys that _are not_ in .env but are required in this.requiredKeys const missingKeys = this.requiredKeys.map(key => { // get this required key from the .env.* file const variable = this.getEnvironmentVariable(key); // if the variable is not defined if (variable === undefined || variable === null) { return key; } }) // filter out any undefined values .filter(value => value !== undefined); // if any keys are missing, throw an error. if (missingKeys.length) { const message = ` The following required env variables are missing: ${missingKeys.toString()}. Please add them to your ${envFile} file `; throw new Error(message); } // re-configure dotenv with the new file dotenv.config({ path: path.resolve(process.cwd(), envFile), }); } getEnvFile(environment: Environment): string { switch (environment) { case 'development': return this.dotEnvDevelopment; case 'test': return this.dotEnvTest; case 'production': case 'ci': default: return this.dotEnvDefault; } } getEnvironmentVariable(variable: string): string{ return process.env[variable]; } getEnvironment(): Environment | null { return (this.getEnvironmentVariable('NODE_ENV') as Environment); } isDevelopment(){ return this.getEnvironment() === 'development'; } isTest(){ return this.getEnvironment() === 'test'; } isProduction(){ return this.getEnvironment() === 'production'; } isCI(){ return this.getEnvironment() === 'ci'; } } const env = new Env(); export default env;

And voila!

You now have a handy class you can use anywhere in your project to:

  • Replace any instances of process.env.NODE_ENV === "development" with env.isDevelopment(), which, in my opinion, is much cleaner.
  • Ensure required environment variables are defined before your project starts up. If you're a hard-worker, you might even want to adjust the script to require certain variables in different environments. For instance, always require NODE_ENV regardless of the environment but only require JWT_SECRET in production.

Updates

  • 28/01/22 - 15:21 - Wording changes, Changed Title

Liked this article? Consider leaving a donation. 😇

Loved this article? Consider leaving a donation to help keep this ambitious fish swimming.

Buy Me a CoffeeDonate with Paypal

btc: bc1qedugpzcgutcmm7qefkhc25eh5dwrwsz7dyleg3

Copyright © 2022 Bradley K.