Create environment specific configuration for a Node app

What we will be doing in this post.

We will be solving two main requirements.

  • Manage sensitive configuration values safely within our app. Additionally these settings will also need to be environment-specific. To accomplish this we use the dotenv module to load data into environment variables not stored in the code.
  • Ensure application configuration is managed in a single location. We meet this criteria by creating a config module that handles all application configuration. The config module will also include environment-specific overrides.

Overview of files created

.
├── config
│   ├── env
│   │   ├── defaults.js
│   │   └── development.js
│   └── index.js
... rest of your Node project files ...
├── .env
└── .env.sample

Install dependencies

We keep the dependencies low and require only two packages to be installed - dotenv and lodash.

Start by installing the dotenv module using your package manager of choice, be it NPM npm i dotenv --save or Yarn yarn add dotenv. The usage of this module is explained in the next section.

Next we require the merge function from the excellent lodash library.

It seems to be so ubiquitous that you possibly already have it installed as a dependency for your project. But in case you don't, install it using either NPM npm i lodash --save or Yarn yarn add lodash

We use lodash instead of the native Object.assign to ensure a safe, deep merge can take place when combining configurations later on.

If you wish to read more on Object.assign and the way it handles deep merges then be sure to check out the Mozilla Developer Network documentation on the subject.

Manage sensitive configuration values

The dotenv NPM module loads credentials stored in a .env file and saves it in environment variables inside process.env for use within our code.

Dotenv is a zero-dependency module that loads environment variables from a .env file into process.env. Storing configuration in the environment separate from code is based on The Twelve-Factor App methodology.

It is important for security that you do not commit any of your sensitive configuration to version control as these often include items such as database username and password.

The .env file used by this module is stored in the root of the project and should not be added to version control. So be sure to add it to your .gitignore file or the equivalent ignore list in your version control of choice.

Another goal is to ensure our configuration is environment-specific.

Your development environment will likely have different database access credentials compared to another developer's local machine setup also working on the project. In the same way your credentials for development will differ to staging. Storing this in the code would soon become difficult to maintain without collisions.

The dotenv module allows us to easily accomplish both goals.

It is often convention to create a .env.sample file with helpful defaults set to safe values. This file is committed to version control. It will be used by anybody working on the project as a template to create the .env file storing the actual credentials and configuration settings.

Create this file now in the root of your project.

touch .env.sample  

Here is an example of a .env.sample file with common settings.

DB_HOST=localhost  
DB_NAME=database_name_here  
DB_USER=database_user_here  
DB_PASS=database_password_here

PORT=3000  

Now to get started on the project you need merely copy .env.sample to .env and replace the defaults with actual values.

To reiterate, it's important to note that the only thing this module does is to take settings from a .env file and populate variables in process.env with the same values.

We will cover how these settings are used further down in the Load the Configuration section.

Create the config module scaffolding

The previous section loaded configuration into process.env but we definitely don't want to sprinkle calls to that throughout our code. So instead we'll create a single config module that will handle all of this in one maintainable place.

We start by creating a config directory off of the project root. Within this we create a env folder which will contain some default and environment-specific overrides - including making use of the data loaded by dotenv.

Run the commands from your project root.

mkdir -p config/env  
touch config/index.js  
touch config/env/{defaults,development}.js  

Create default configuration

Now that the general scaffolding has been created we will populate the default application configuration.

Add this code to the config/env/defaults.js file.

'use strict'

const config = {  
  port:  process.env.PORT || 3000,

  database: {
    client: 'postgresql',
    connection: {
      host     : process.env.DB_HOST,
      user     : process.env.DB_USER,
      password : process.env.DB_PASS,
      database : process.env.DB_NAME,
      charset  : 'utf8'
    },
    debug: false
  },
  logger: {
    level: 'info',
    format: 'tiny'
  }
};

// Set the current environment or default to 'development'
process.env.NODE_ENV = process.env.NODE_ENV || 'development';  
config.env = process.env.NODE_ENV;

module.exports = config;  

Define all of the configuration expected by your app inside defaults.js. This is an important point to stress as our environment-specific settings should only ever override values already defined in defaults.js.

Be sure to set your defaults to sensible values. I favour setting defaults that I would want for production such as turning off any debug output. We'll learn how to override these settings in different environments (such as development) in the next section.

This module is the only location in our app that will make direct use of environment variables (such as process.env.DB_HOST). The rest of our app will reference the keys we define in the JSON exported by the config module.

Set environment overrides

Next we create environment-specific configuration overrides by creating files inside config/env named after our environments. It is important that these are named the same as the value we use in process.env.NODE_ENV.

The files inside config/env named after environments will contain only the settings you wish to override for that environment.

Example of a config/env/development.js file.

'use strict'

const config = {  
  database: {
    debug: true
  },
  logger: {
    level: debug,
    format: 'combined'
  }
};

module.exports = config;  

A good use for the development override is to set more verbose debug output.

In this example we create config/env/development.js but you would do the same for any other environments across your stack requiring overrides to the default settings.

Load the configurations

Excellent - we've now created the application's default and environment-specific configurations but to use it we need to code our config loader.

The config/index.js file has the responsibility of loading our default configuration and then any environment-specific overrides.

To accomplish that add this code to config/index.js.

'use strict';

const merge = require('lodash/merge');

// Load .env settings into process.env
// Will fail silently if no .env file present.
require('dotenv').config();

// Load our own defaults
const config = require('./env/defaults');

// Load environment-specific settings
let localConfig;  
try {  
  // The environment file might not exist
  localConfig = require(`./env/${config.env}`);
  localConfig = localConfig || {};
} catch(err) {
  localConfig = {};
}

// merge the config files
// localConfig will override defaults
merge({}, config, localConfig);

module.exports = config;  

We start by loading only the merge functionality from lodash.

Next, the line require('dotenv').config(); is what checks for the existence of a .env file and loads the contents into environment variables.

The default configuration is loaded next and stored in a config variable.

The block of code related to localConfig first attempts to include the overrides for the environment defined in config.env. If it doesn't exist then localConfig is safely set to an empty object.

We are now ready to merge the default and environment overrides into a single config object which is exported.

Using the configuration within the app

One benefit to following this config module approach is that it eliminates making process.env calls throughout our app. Instead we simply require our config wherever it is needed.

Example of using this new configuration in an Express app.

'use strict';

const express = require('express');  
const app = express();  
const config = require('./config');  
const logger = require('morgan');

app.set(config.port);  
app.use(logger(config.logger.format));

app.listen(app.get('port'), function () {  
  console.log(`App running on port ${app.get('port')}`)
});

module.exports = app;