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.
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
andNODE_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 NODE_ENV 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 inconfiguration.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.
Top comments (9)
I followed your tutorial, and it helped me alot in managing the variables in the app, but I have one comment, rather than
process.cwd()
you should adjust it to be relative the app itself not the absolute directory, so I prefer using 'src/config/env/${process.env.NODE_ENV}.env'Anyway Good job!!
Great post. I inherited a React project with all the credentials checked into GitHub and had to scramble to fix it.
Another area you should address is database migrations. After implementing your suggestions in the code, I still struggled with the database migration part of it. package.json does not expand environment variables and I couldn't figure out how to get the migration tool to switch between env files. I finally ended up with a kluge using dotenvx + multiple script entries in the package.json file to do it. Ugh. Ugh., Ugh.
"migration-run": "dotenvx run --env-file=\".env.dev\" -- npm run typeorm:cli -- migration:run",
"migration-run:staging": "dotenvx run --env-file=\".env.staging\" -- npm run typeorm:cli -- migration:run",
"migration-run:prod": "dotenvx run --env-file=\".env.prod\" -- npm run typeorm:cli -- migration:run",
"migration-revert": "dotenvx run --env-file=\".env.dev\" -- npm run typeorm:cli -- migration:revert",
"migration-revert:staging": "dotenvx run --env-file=\".env.staging\" -- npm run typeorm:cli -- migration:revert",
"migration-revert:prod": "dotenvx run --env-file=\".env.prod\" -- npm run typeorm:cli -- migration:revert"
This is amazing. It worked great for me in my project.
However, I want to ask if I also want to use the same approach for my Prisma URL?
According to their documentation, it seems that they're implementing a different approach that might not work with this one.
Can you please help?
Hi, nice article very appreciated!
I have a nestjs monorepo and had to do the following for the app I am working on:
In the nestjs docs, it says nestjs does not move assets automatically to the dist folder. Maybe that is the reason.
Cheers
Nice setup with Nest.js !!
Nice ;) Great job
Thanks for this useful article !
thank u so much !!!
Great content .Bow to manage these env files in Dockerfile for different environment ?