Manage your Node.js .env file with Envalid : A clean way to access your secrets keys in prod ⚡️

1. What is a .env file?

A .env or dotenv file of an app is a text file that contains environment-specific configurations. Most of the time between Local, Staging, and Production environments, your app will need to behave visually the same but behind the scenes, each of these environments can be configured with different keys, constants, API Keys, etc.

2. what a .env file looks like

As I previously said,  a .env file is a text file, containing lines of declaration where each declaration represent a single variable. It is conventionally advised to set each variable name inside the file with uppercase words separated by an underscore ( _ ), follow with  =   (without space) by the value. Here is an example:

VARIABLE_NAME=value

3. Access it in a Node.js app

In Node.Js, there is an object called process which is a global that provides information about, and control over, the current Node.js process. As a global, it is always available to Node.js applications without using require() or import. So in Node.js, you will need to write process.env.VARIABLE_NAME to access the value set in the .env file.

So is it possible to access configurations variables with the process object, what is the purpose of ENVALID ? 🤔

According to Envalid README file, Envalid is a small library for validating and accessing environment variables in Node.js (v8.12 or later) programs, aiming to:

  • ensure that your program only runs when all of its environment dependencies are met
  • give you executable documentation about the environment your program expects to run in
  • give you an immutable API for your environment variables, so they don't change from under you while the program is running.

4. Setup 🏄🏻‍♂️

In this post, i will use typescript with this open-source node-typescript-starter project to show you how it works.

  • Open your favorite terminal and clone the starter template with git clone https://github.com/jsynowiec/node-typescript-boilerplate.git your_project_name  and cd your_project_name
  • Install node dependencies with npm install. If you are using YARN as a package manager, you will need to delete the package-lock.json at the root folder to avoid conflict with yarn's -lock.json file.
  • Delete the following block inside the package.json (starting at line 5) file to remove targeting a specific node.js version. In this post, we will just focus on looking at how Envalid works.
 "engines": {
    "node": ">= 16.13 <17"
  },
  • We will also need to install, a useful package that will restart the server each time a file changes and ts-node  a typescript execution engine for node.  So npm install --save-dev nodemon ts-node  
  • replace start script inside package.json with the following to add nodemon.
 "start": "npm run build && nodemon build/src/main.js",
  • then in the terminal, npm start You should be able to see the output.

Now call the default greeter(name:string) a function that returns a Promise with

greeter('James Bond').then((result) => console.log(result));

this will automatically trigger a build and print Hello, James Bond in the terminal

[nodemon] clean exit - waiting for changes before restart
[nodemon] restarting due to changes...
[nodemon] starting `ts-node src/main.ts`
[nodemon] restarting due to changes...
[nodemon] starting `ts-node src/main.ts`
Hello, James Bond
[nodemon] clean exit - waiting for changes before restart

5. Adding .env 🤾🏼‍♂️

For this app, we will implement something very simple. The app will be a simple one with the main function that displays in the console the redirection link corresponding to a specific user of an array according to its ROLE if we

create a .env file at the root of the folder and add it to .gitignore file. For this post, let's consider our app will store 3 variables, that are:  

  • ADMIN_REDIRECT_URL=/v1/custom/path/admin/login  which is where to redirect the user if it's an admin.
  • DEFAULT_REDIRECT_URL=/v1/custom/path/user/login  which is the default redirect url for users
  • DASHBORD_BASE_URL=https://your_appname.com

Your .env file should look like this

ADMIN_REDIRECT_URL=/v1/custom/path/admin/login
DEFAULT_REDIRECT_URL=/v1/custom/path/user/login
DASHBORD_BASE_URL=https://your_appname.com # REPLACE THIS WITH ANY URL 

Now let's access these variables in our app.

install Envalid with npm install envalid or yarn add envalid if you are using yarn.

create a new file env.ts inside the src folder and add the following code.

import { cleanEnv, str } from 'envalid';
const env = cleanEnv(process.env, {
  ADMIN_REDIRECT_URL: str({ default: 'any_default_url' }),
  DEFAULT_REDIRECT_URL: str({ default: 'any_default_url' }),
  DASHBORD_BASE_URL: str({ default: 'any_default_url' }),
});
export default env;

replace the code inside main.ts file with the following :

import env from './env';

enum UserType {
  admin,
  customer,
}
interface User {
  name: string;
  role: UserType;
}
const users = [
  { name: 'John do', role: UserType.admin },
  { name: 'Marc Vince', role: UserType.customer },
  { name: 'Stephen Huke', role: UserType.admin },
];

const printHello = (user: User): string => {
  const { name, role } = user;
  if (role === UserType.admin) {
    return `Hello ${name}! , Your login URL is: ${env.DASHBORD_BASE_URL}${env.ADMIN_REDIRECT_URL}`;
  }
  return `Hello ${name}! , Your login URL is: ${env.DASHBORD_BASE_URL}${env.DEFAULT_REDIRECT_URL}`;
};

const start = () => {
  users.forEach((user) => {
    console.log(printHello(user));
  });
};

start();

This will print the following in the console :

[nodemon] starting `ts-node src/main.ts`
Hello John do! , Your login URL is: https://your_appname.com/v1/custom/path/admin/login
Hello Marc Vince! , Your login URL is: https://your_appname.com/v1/custom/path/user/login
Hello Stephen Huke! , Your login URL is: https://your_appname.com/v1/custom/path/admin/login

You can see that the .env variables have been well displayed.

6. Envalid API

if you look at the env.ts file, you will notice the import of cleanEnv() to load environment variables. cleanEnv() returns a sanitized, immutable environment object, and accepts three positional arguments:

  • environment - An object containing your env vars (eg. process.env)
  • validators - An object that specifies the format of required vars.
  • options - An (optional) object, which supports the following key:
  • reporter - Pass in a function to override the default error handling and console output.

By using cleanEnv(), it will log an error message and exit (in Node) or throw (in browser) if any required env vars are missing or invalid. You can override this behavior by writing your own reporter.

7. Validator types

Node's process.env only stores strings, but sometimes you want to retrieve other types (booleans, numbers), or validate that an env var is in a specific format (JSON, URL, email address). To this end, the following validation functions are available:

  • str() - Passes string values through, will ensure a value is present unless a default value is given. Note that an empty string is considered a valid value - if this is undesirable you can easily create your own validator (see below)
  • bool() - Parses env var strings "1", "0", "true", "false", "t", "f" into booleans
  • num() - Parses an env var (eg. "42", "0.23", "1e5") into a Number
  • email() - Ensures an env var is an email address
  • host() - Ensures an env var is either a domain name or an ip address (v4 or v6)
  • port() - Ensures an env var is a TCP port (1-65535)
  • url() - Ensures an env var is a URL with a protocol and hostname
  • json() - Parses an env var with JSON.parse

Each validation function accepts an (optional) object with the following attributes:

  • choices - An Array that lists the admissible parsed values for the env var.
  • default - A fallback value, which will be present in the output if the env var wasn't specified. Providing a default effectively makes the env var optional. Note that default values are not passed through validation logic.
  • devDefault - A fallback value to use only when NODE_ENV is not 'production'. This is handy for env vars that are required for production environments but optional for development and testing.
  • desc - A string that describes the env var.
  • example - An example value for the env var.
  • docs - A url that leads to more detailed documentation about the env var.

8. Custom validators

You can easily create your own validator functions with envalid.makeValidator(). It takes a function as its only parameter, and should either return a cleaned value or throw if the input is unacceptable:

import { makeValidator, cleanEnv } from 'envalid'
const twochars = makeValidator(x => {
    if (/^[A-Za-z]{2}$/.test(x)) return x.toUpperCase()
    else throw new Error('Expected two letters')
})

const env = cleanEnv(process.env, {
    INITIALS: twochars()
});

9. Error Reporting

By default, if any required environment variables are missing or have invalid values, Envalid will log a message and call process.exit(1). You can override this behavior by passing in your own function as options.reporter. For example:

const env = cleanEnv(process.env, myValidators, {
    reporter: ({ errors, env }) => {
        emailSiteAdmins('Invalid env vars: ' + Object.keys(errors))
    }
})

Additionally, Envalid exposes EnvError and EnvMissingError, which can be checked in case specific error handling is desired:

const env = cleanEnv(process.env, myValidators, {
    reporter: ({ errors, env }) => {
        for (const [envVar, err] of Object.entries(errors)) {
            if (err instanceof envalid.EnvError) {
                ...
            } else if (err instanceof envalid.EnvMissingError) {
                ...
            } else {
                ...
            }
        }
    }
})

Thanks for reading, the source code is available here.

Follow me on social media to stay up to date with the latest posts.