loading...
Cover image for 12-factor Node.js application configuration management without the `config` npm package

12-factor Node.js application configuration management without the `config` npm package

hugo__df profile image Hugo Di Francesco Originally published at codewithhugo.com on ・3 min read

The config npm package is great (npmjs.com/package/config), but it encourages confusing and non-12-factor-app-compliant patterns.

We’ll look at some of the patterns it encourages and why they’ll bring you struggles down the road as well a simple, single-file, no-dependency way to define your configuration.

Sprawling configuration: hard to pinpoint where config is set

The main thing it encourages is configuration sprawl: some of your configuration lives in JSON files, some of your configuration comes from environment variables (and is glued together using JSON files). Some config fields change depending on NODE_ENV, others do not.

Worst of all, config is dynamically loaded using a config.get('path.in.the.json.config.object') call. This creates an affordance for users to have deeply-nested configuration object(s), which isn’t desirable, your configuration should be minimal and it shouldn’t live in application code.

See the following from the “config” section in “The Twelve-Factor App” (see it in full at 12factor.net/config):

Apps sometimes store config as constants in the code. This is a violation of twelve-factor, which requires strict separation of config from code. Config varies substantially across deploys, code does not.

Non-granular configuration

Here’s another reason why having a package that makes it easy to have config objects isn’t a good idea according to 12 Factor again (see the full config section at 12factor.net/config):

In a twelve-factor app, env vars are granular controls , each fully orthogonal to other env vars. They are never grouped together as “environments” , but instead are independently managed for each deploy.

Having a default.json, production.json, test.json, custom-environment-variables.json is just not 12-factor, since you’re not supposed to group your config. It should be “here’s a database connection URL”, “here’s a backing service URL”, “here’s a cache connection string”.

It entices developers down the line keep adding switches and settings in a "database": {} field. Those concerns will not be orthogonal to each other, what’s more, they’re likely to be application-level concerns, eg. “should the database client try to reconnect?”. That’s not something you should be overriding with environment variables or toggling across the environments. It’s a setting that should be hard-coded into the application depending on whether or not the database is critical for example.

A single config.js file

config.js at the root of you application would look like this:

module.exports = {
  NAME_OF_CONFIG: process.env.NAME_OF_CONFIG || 'default-config',
  DATABASE_URL: process.env.DATABASE_URL,
  REDIS_URL: process.env.REDIS_URL || 'localhost:6379',
  X_ENABLED: process.env.X_ENABLED === 'true',
};

In the above, there are examples for how you would default a configuration variable (NAME_OF_CONFIG, REDIS_URL) and how you would check a boolean flag (X_ENABLED).

Making process.env fit for purpose

In Node.js process.env variables (environment variables) are strings, JavaScript is pretty loose with types, but it’s sometimes useful to convert process.env variables to another type.

Parsing a number from process.env

const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT, 10)
module.exports = {
  SESSION_TIMEOUT
};

Converting to Boolean from process.env

Comparing against the 'true' string tends to be enough:

module.exports = {
  IS_DEV: process.env.IS_DEV === 'true',
};

Consuming config.js

To get data from config.js would be like the following, where we conditionally set some 'json spaces', a request timeout and listen on a port with an Express app,

const { REQUEST_TIMEOUT, X_ENABLED, PORT } = require('./config')
const express = require('express')
const app = express()

if(X_ENABLED) {
  app.set('json spaces', 2)
}

app.use((req, res, next) => {
  req.setTimeout(REQUEST_TIMEOUT); 
  next()
})

app.listen(PORT, () => {
  console.log(`App listening on ${PORT}`);
});

Bonus: getting values from a .env file

Sometimes you’ll want to export values from a .env file into your shell sessionThe following snippet does just this and is an extract of this bash cheatsheet.

export $(cat .env | xargs)

Note the above works for *NIX environments

unsplash-logo
Filip Gielda

Posted on by:

hugo__df profile

Hugo Di Francesco

@hugo__df

Developer, JavaScript, CSS and web. Writing at Code with Hugo.

Discussion

markdown guide
 

Excellent and much-needed article. 👏 A lot of projects struggle with correct config setup. One thing that I would do slightly differently:

I think type conversion in config is not enough. Validating the required env vars with a library like joi gives you automatic type casting and won't let the process start with faulty or missing env vars. In some cases, a process can run for a long time until it needs a specific env var - like an SMTP address for email sending. These could cause random failures or - at worst - silent errors. Not starting the process and giving a clear message about env var issues is much cleaner.

This kind of issue happens more often than you may think. Large projects tend to have separate dev and devops teams. When a dev adds a new required env var to their .env file, they tell the devops team to add the same env var before rolling out the next deployment. This info can disappear among slack messages or email threads pretty easily.

 

That's a good shout, I didn't include it because your env variables should be mainly used directly and during app bootstrap where I expect things will fail if the vars aren't defined.