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 intoprocess.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.
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.
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
.
const merge = require('lodash/merge');
// Load .env settings into process.env
// Will fail silently if no .env file present.
if (process.env.NODE_ENV !== 'production') {
require('dotenv').config();
}
// Load our own defaults which will grab from process.env
const config = require('./env/defaults');
// Only try this if we're not on Production
if (process.env.NODE_ENV !== 'production') {
// 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.
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;