Managing Your Distributed Node.js Application Environment and Configuration

Managing Your Distributed Node.js Application Environment and Configuration

See how to effectively manage the environment and configuration of your distributed Node.js applications.

Node.js applications rely on environment variables to differentiate between…environments!

This means that applications running locally often behave differently to those being deployed to testing, staging, or production environments. Often, this just means listening on a different port, or pointing to a different database url.

The Largest River, my first foray into distributed application development, leverages both environment variables and configuration files to differentiate between the mode in which the code is being run, and the cloud region where it's being run.

In this article, I'm going to demonstrate how the dotenv and node-config NPM packages can be used together to keep your Node.js application code organized across environments.

Setting the Environment

Application configuration can be done in a variety of ways, depending on the complexity of the system being built. In The Largest River project, I've chosen to use environment variables minimally, only setting NODE_APP_INSTANCE and NODE_ENV.

Let's see how this works.

#.env
NODE_APP_INSTANCE=los-angeles
# server.js

if (process.env.NODE_ENV === "development") { 
  require("dotenv").config();
}

// rest of file has access to process.env.NODE_APP_INSTANCE
if (process.env.NODE_APP_INSTANCE === “london”) {
  // connect to database node nearest to London
}
...

You might be wondering where I've set the value of NODE_ENV. Well, this variable is set to 'development' by default. When running our node script, the following two commands are equivalent.

NODE_ENV=development node server.js

node server.js

In production, we set the variables explicitly in the startup script.

# startup_script.sh
...
// setting NODE_APP_INSTANCE environment variable from instance metadata
NODE_APP_INSTANCE=$(curl http://metadata.google.internal/computeMetadata/v1/instance/attributes/instance_id -H "Metadata-Flavor: Google")
echo "NODE_APP_INSTANCE=${NODE_APP_INSTANCE}" | sudo tee -a /etc/environment

//setting NODE_ENV to production
echo "NODE_ENV=production" | sudo tee -a /etc/environment
source /etc/environment

//starting our node server with explicit command line arguments
NODE_ENV=$NODE_ENV NODE_APP_INSTANCE=$NODE_APP_INSTANCE node server.js

This ensures that even on server reboots, our application will start reliably, with the proper environment set. The /etc/environments file is system-wide and persistent, making it a reasonable place to store our environment variables on production servers.

Beyond the Basics

With our environment settled, it's time to check out our application configuration.

What if our app needs more expressive configuration? Environment variables can do the job, but their values are always strings. What if we want to include objects and nested data types?

Some developers would say, "Stringify your JSON and add it to your environment."

While I understand the sentiment, I disagree. It is much more pleasant to work directly inside a JSON file. So long as you take care to not check sensitive information into source control, this can be a powerful tool in your development arsenal.

Here's an example, utilizing the node-config package:

# default.json
{
  "Databases": [
    {
      "id": "single_region",
      "type": "single_region",
      "host": "[HOST_FOR_DATABASE]", 
      "primaryRegion": "us-west2",
      "username": "[DB_USERNAME]",
      "password": "[DB_PASSWORD]",
      "cert": "[PATH_TO_DB_CERT]",
      "dev_cert": "[PATH_TO_CERT_IN_DEVELOPMENT]",
      "label": "Single-region, multi-zone",
      "sublabel": "3 nodes deployed in US West",
      "nodes": [
        {
          "coords": [35.37387, -119.01946],
          "label": "Bakersfield",
          "zone": "us-west2",
        },
        {
          "coords": [34.95313, -120.43586],
          "label": "Santa Maria",
          "zone": "us-west2",
        },
        {
          "coords": [32.71742, -117.16277],
          "label": "San Diego",
          "zone": "us-west2",
        }
      ]
    },
    ...
  ]
}
# server.js

const config = require("config");
const Databases = config.get("Databases");

By utilizing a JSON configuration file, we're able to easily edit our database details. Given that The Largest River features multiple YugabyteDB Managed databases with varying deployments, it is helpful to not be restricted to key-value string pairs.

As a matter of interest, this is what working with the same configuration would look like in an environment variable.

# .env
DATABASES='{"Databases":[{"id":"single_region","type":"single_region","host":"[HOST_FOR_DATABASE]","primaryRegion":"us-west2","username":"[DB_USERNAME]","password":"[DB_PASSWORD]","cert":"[PATH_TO_DB_CERT]","dev_cert":"[PATH_TO_CERT_IN_DEVELOPMENT]","label":"Single-region, multi-zone","sublabel":"3 nodes deployed in US West","nodes":[{"coords":[35.37387,-119.01946],"label":"Bakersfield","zone":"us-west2"},{"coords":[34.95313,-120.43586],"label":"Santa Maria","zone":"us-west2"},{"coords":[32.71742,-117.16277],"label":"San Diego","zone":"us-west2"}]}]}'
# server.js
if (process.env.NODE_ENV === "development") { 
  require("dotenv").config();
}

const Databases = JSON.parse(process.env.DATABASES)

Earlier we set an environment variable called NODE_APP_INSTANCE. This variable can be used to set instance-specific configuration in a multi-instance deployment. This can be hugely helpful if instances of the same application need to behave differently.

The Largest River is comprised of six application instances, each connecting to different database nodes, depending on their configuration.

The Largest River Application Instances

The Largest River Application Instances

If a default.json needs to be overridden, these application instances will read from production-los-angeles.json, development-mumbai.json, etc. depending on the environment and cloud region.

Wrapping Up

The way you manage your application environment and configuration is entirely up to you. After all, you're the one that has to work with it!

There are no hard and fast rules. Personally, I'm always looking for ways to remove configuration details from my application logic. This has a number of benefits. For example, as my applications evolve, there is a single source of truth, and reduced code duplication.

I hope you'll consider some of these tips when you create your next distributed Node application. And remember, this is your journey, do what works best for you!

Look out for my next blog, which will look at distributed SQL database configurations in depth!