Managing multiple environments in NestJS

Managing multiple environments in NestJS

Development, test, staging, production environment variables

So, recently I started working on a new startup and each time I do, I try to adopt a new technology be it language or framework . (this is not always recommended, in this case I have previous experience with NestJS)

This time around I chose to adopt NestJS. Have used it before for pet projects and found it really fun so I thought why not to use it as the backend for my new startup? Felt like a no-brainer.

The problem

As this is not my first rodeo with startups, I actually take time to set up the backend properly instead of being in an MVP rush mode. One of the things that needed configuration early on, was the separation of environment variables between different modes.

e.g development, test, staging, and production

Looking at the docs there is no real suggestion on how to do that but it gives you breadcrumbs here and there on how to achieve such a thing by putting the pieces together.

So here I am documenting how I did it so you don't have to waste more time on it. Ready? Let's go.

Step 1

Create the following structure in the root of your NestJS app.

Screenshot 2021-08-16 at 7.37.13 PM.png

Step 2 - Initializing ConfigModule

Open up your app.module and write the following

import { ConfigModule } from '@nestjs/config';

// ...skipping irrelevant code

@Module({
  imports: [
    ConfigModule.forRoot(), 
    PrismaModule,
    ProductsModule,
    AuthModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})

// ...skipping irrelevant code

if we don't pass any options to the ConfigModule by default it is looking for a .env file in the root folder but it cannot distinguish between environments. Let's move onto the next steps where we make the ConfigModule smarter in where to look and what to load

Step 3 - Populating the development.env file

Let's populate the development.env file as a first step towards creating separate environments.

JWT_SECRET=luckyD@#1asya92348
JWT_EXPIRES_IN=3600s
PORT=3000

Step 4 - Populating the configuration file

configuration.ts - its main purpose is to create an object (of any nested level) so that you can group values together and make it easier to go about using it.

Another benefit is to provide defaults in case the env variables are undefined and on top of that you can typecast the variable as it's done for the port number below.

// configuration.ts

export const configuration = () => ({
  NODE_ENV: process.env.NODE_ENV,
  port: parseInt(process.env.PORT, 10) || 3001,
   jwt: {
    secret: process.env.JWT_SECRET,
    expiresIn: process.env.JWT_EXPIRES_IN,
  }
});

Then let's pass options to the ConfigModule to use this configuration file like so:


import { configuration } from '../config/configuration'; // this is new

// ... skipping irrelevant code

@Module({
  imports: [
    ConfigModule.forRoot({ 
       envFilePath: `${process.cwd()}/config/env/${process.env.NODE_ENV}.env`,
       load: [configuration] 
    }), 
    PrismaModule,
    ProductsModule,
    AuthModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})

// ...skipping irrelevant code

We have now used two options to configure the ConfigModule.

  • load

This should be pretty self-explanatory, that it loads the configuration file we are giving it and does all the goodies mentioned above.

  • envFilePath

We are pointing the module (underneath its using the dotenv package) to read an .env file based on the process.env.NODE_ENV environment variable.

process.cwd() is a handy command that provides the current working directory path

BUT we are just now loading the variables, how do you expect the module to make use of the process.env.NODE_ENV variable before the env variables are loaded?!

Well, read more on the next step!

Step 5 - Initializing the NODE_ENV env variable

First of all, what is the NODE_ENV variable for? Well, it's a practice used by devs to denote which environment they are using.

In short, NODE_ENV lets the app know if it should run in the development, production, you-name-it environment by looking at its value.

There are actually many ways to go about loading env variables, and one of them is to set the variable inline to the execution script like so:

// package.json

"scripts": {
    "prebuild": "rimraf dist",
    "build": "nest build",
    "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
    "start": "nest start",
    "start:dev": "NODE_ENV=development nest start --watch",
    "start:debug": "nest start --debug --watch",
    "start:prod": "NODE_ENV=production node dist/main",
    "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:cov": "jest --coverage",
    "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
    "test:e2e": "jest --config ./test/jest-e2e.json",
  },

Notice the NODE_ENV=development and NODE_ENV=production above.

When we execute the script using one e.g npm run start:dev it will actually set the variable and will be accessible in your NestJS app. Cool, this gives an answer to the question we had above.

Windows users must install cross-env package as windows doesn't support this way of loading variables and alter the commands like so "start:dev": "cross-env NODE_ENV=development nest start --watch"

Step 6 - Usage

We now have two methods of reaching the values of the env variables

Method 1

As seen above we can make use of the process.env. to access the variables. However, this has some drawbacks in terms of accessing env variables during module instantiation so be mindful of that.

Method 2

Using the ConfigService to access the variables. Setting up the ConfigModule now gives us access to its service which consequently gives us access to the variables

Example

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { ConfigService } from '@nestjs/config';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService, private configService: ConfigService) {}

  @Get()
  getHello(): string {
    console.log(this.configService.get<string>('jwt.secret')
  }
}

Step 7 - Update .gitignore

If you do a git status you should notice that the development.env file is being watched and will be committed. While that is somewhat OK as long as you don't use the same values for example in the production.env lets update .gitignore to ignore .env files:

// .gitignore

// add at the bottom

**/*.env
!config/env/development.env

What it says here, is to ignore all .env files except for development.env

(BONUS) - Validating the env variables

Now we have come full circle but we can go one step further to ensure that our variables are in the correct type and loaded.

Step 1 - Install joi

This library will do the heavy lifting of validating our env variables by comparing them against a schema we provide.

npm install joi

OR

yarn add joi

Step 2 - Populate validation.ts

import * as Joi from 'joi';

export const validationSchema = Joi.object({
  NODE_ENV: Joi.string().valid(
    'development',
    'production',
    'test',
    'provision',
  ),
  JWT_SECRET: Joi.string().required(),
  JWT_EXPIRES_IN: Joi.string().required(),
  PORT: Joi.number().default(3000),
});

So what we did above was to make sure that the NODEENV is one of the mentioned strings, the JWT* variables are strings and required, and we require the port to be a number and have a default value (hence why we don't required() a value to be present)

Notice that the validation schema naming must be exactly like it's in the .env file and NOT how you wrote the names in configuration.ts

Step 3 - Update options in ConfigModule

import { validationSchema } from '../config/validation';

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: `${process.cwd()}/config/env/${process.env.NODE_ENV}.env`,
      load: [configuration],
      validationSchema,
    }),
    PrismaModule,
    ProductsModule,
    AuthModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})

So here we imported and provided the validationSchema to the module.

Exercise: Try setting up NODE_ENV something else than the four values defined in the validation schema and see what happens

(BONUS 2) - Avoid the need of importing the config module everywhere

There is a handy option to avoid having to import the config module in every module that is being used which is pretty neat. Its called isGlobal and below you can find how it's used

@Module({
  imports: [
    ConfigModule.forRoot({
      envFilePath: `${process.cwd()}/config/env/${process.env.NODE_ENV}.env`,
      isGlobal: true,
      load: [configuration],
      validationSchema,
    }),
    PrismaModule,
    ProductsModule,
    AuthModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})

Summary

You have set up a flexible way of setting up your env variables for each environment in a non-complicated manner while also maintaining type and value integrity by validating the env variables against a schema.

I hope you found this useful and if you want to keep in touch you can always find me on Twitter.