Managing Node.js Environment Config

6 minute read

When building applications, it's important to consider up front how you'll store your application's configuration. As described in classic Twelve-Factor App, config is considered anything that "varies between deployments", and often includes values for log levels, feature flags, API hostnames, etc. By managing this configuration outside of the application, we're more likely to be able to build the application build/container/package once and run it anywhere (multiple environments, with different configs, etc.).

Disclaimer - Avoid using environment variables for secrets

Before we start, a quick disclaimer - don't use environment variables to store application secrets (database passwords, API keys, session keys, for instance). Environment variables are generally globally available to both your application, as well as all of its dependencies. A malicious third party dependency could easily get your environment values and send them to a remote server.

I'll follow up with another blog posts that recommends some better options for managing application secrets.

Using the Environment

Using environment variables as a vehicle to pass this configuration to your application is a good place to start. In Node.js, process.env is the global that can be used to access environment variables in your application code. This works out of the box, so long as you've populated the environment variables before your application starts.

As an example let's use a basic express application that configures a logger at a provided level, and starts the application on a supplied port. We can hoist our LOG_LEVEL and PORT values to the environment to allow us to run the same application with different options. When developing locally, loading environment variables from values can be easily performed with popular libraries like dotenv. In production or other shared environments, environment variables are more likely to be managed and set by your runtime or container management service.

From an application standpoint, basic access to these variables is demonstrated below. You'll often see developers using the JS logical "or operator" (||) to set sensible defaults for environment variables that may not have been provided. You may also find applications throwing an exception if an environment variable that's required doesn't exist:

// index.ts
import express from 'express';
import pino from 'pino';

// grab values from the environment, configure sensible defaults
const logLevel = process.env.LOG_LEVEL || 'info';

const port = process.env.PORT;

if (port == null) {
  throw new Error('PORT is a required environment variable!');
}

const logger = pino();
logger.level = logLevel;

const app = express();

app.get('/health', (req, res) => {
  res.status(200).json({
    status: 'OK',
    logLevel,
  });
});

app.listen(port, () => {
  logger.info(`Application listening on port ${port}`);
});

The above works fine, but it doesn't scale well. The application's entry point quickly gets noisy, and the environment variable validation checks can often times be hard to read. Fixing an application that won't start, or worse, is silently failing because the environment isn't configured correctly, is never an enjoyable experience.

How can we do this better?

Here are some recommendations for managing the values from your environment more effectively:

Centralize access to configuration

Don't scatter references to process.env throughout your application. Although process.env is a global singleton, we can create a simple helper to access it to gain the benefits of being able to document, validate and cast the environment in a single place:

// getAppEnv.ts

export function getAppEnv(env: NodeJS.ProcessEnv) {
  return {
    port: env.PORT,
    logLevel: env.LOG_LEVEL,
  };
}

Validate and define a contract for your environment

We can extend the helper function above to both validate and cast our values. We'll create an application layer that exists between our application and environment variables.

If the app is missing any environmental value that it requires to run, we'll force the application to fail fast. For any real application, I'd prefer to not setting sensible defaults for environment variables - I'd rather force the team to get familiar with the application's configuration prerequisites than hard-code some magical defaults.

The yup library has some nice helpers for doing both of the above in one step. The code below is written in TypeScript, but can easily be converted to Node.js and/or CommonJS by removing the interfaces and adjusting the import clause.

// getAppEnv.ts

import * as yup from 'yup';

const appEnvSchema = yup.object({
  NODE_ENV: yup.string().required().oneOf(['development', 'production']).default('development'),
  PORT: yup.number().required(),
  LOG_LEVEL: yup.string().required().oneOf(['fatal', 'error', 'warn', 'info', 'debug', 'trace']),
});

export interface AppEnv extends yup.Asserts<typeof appEnvSchema> {
  isProduction(): boolean;
  isDevelopment(): boolean;
}

export function getAppEnv(env: NodeJS.ProcessEnv): AppEnv {
  const validatedData = appEnvSchema.validateSync(env);

  return {
    isProduction: () => validatedData.NODE_ENV === 'production',
    isDevelopment: () => validatedData.NODE_ENV === 'development',
    ...validatedData,
  };
}

You may want to extend this example to convert the config attributes to camelCase to create some separation between your config and application code. Consider this utility method a service that can be used to create any abstractions that your application needs.

These few lines of code helped us achieve the following:

ENV values are validated

Yup's validateSync method will not only validate the schema definition, but, it will also perform casts. In this example, the PORT attribute on the returned object is cast to a number. If validateSync fails, it will throw an error that clearly indicates which attribute failed and why. Validation is currently performed synchronously in this example, with the expectation that this happens fairly infrequently in the app (on app startup, for instance).

Note: I've used the default method for the NODE_ENV to define a default value in the snippet above. This goes against my recommendation for not providing defaults, but, your app may provide compelling reasons to offer defaults.

Helpers are introduced

The isProduction and isDevelopment methods add some syntatic sugar around comparing NODE_ENV === 'development' in the code base. In an express app, you use these helpers in the application to conditionally load non-production middleware.

Clear and concise ENV contracts have been defined

Any developer or teammate can easily open the getAppEnv.ts file and clearly read the schema definition to understand what the application requires from the environment to start up successfully.

The code snippet below demonstrates how to use this helper in the main application. Note that I'm intentionally using this helper on the very first lines of my application entry point. I'd recommend doing the same to ensure that application fails fast if any of its required environment variables are not provided:

// index.ts
import { AppEnv, getAppEnv } from './getAppEnv';

// validate/parse the environment
const appEnv: AppEnv = getAppEnv(process.env);

import express from 'express';
import pino from 'pino';

const logger = pino();
logger.level = appEnv.LOG_LEVEL;

const app = express();

app.get('/health', (req, res) => {
  res.status(200).json({
    status: 'OK',
  });
});

app.listen(appEnv.PORT, () => {
  logger.info(`Application listening on port ${appEnv.PORT}`);
});

These tips should help you lay the foundation for your application's external configuration. Environment variables coupled with a helper written like the one above will definitely help improve the reliability and maintainability of your application.