Environment variables to configure TypeScript application

Broadly, applications might have a couple of categories of configuration. Namely, setup for application business rules and application runtime setting. Rules in different categories have different reasons to change, and they might be implemented with various tools and technics. What is essential for us is that former category rules tend to be environment agnostics. While later category rules are controlled by the application runtime. In the article, we provide a pragmatic approach to handle runtime settings for TypeScript NodeJS applications.

Robert C. Martin writes about the purpose of software architecture ’To be effective, a software system must be deployable. The higher the cost of deployment, the less useful the system is. A goal of software architecture, then, should be to make a system that can be easily deployed with a single action.’ 1 Each type of application deployment requires configuration. Thus, for an application architecture to be effective, the application should be configurable in every deployment environment with a single action.

An essential principle in modern application design is to use the same application code base for all application deployments. Making this statement even more substantial, we require that the same code (the same code path) is executed in all deployments. For example, the programmer can launch unit tests from his IDE. The same tests run on the CI server in the docker container. The application might run in debug mode on the laptop or in the production Kubernetes cluster. The same code path should be executed for the same business operation (given the same application state and input parameters) in all these cases.

Here is an example of violating these principles.

// This is an example of a BAD code.
if (config.develop) {
    conStr = "localhost:3306";
    conUser = "test";
    conPwd = "";
} else if (config.environment === "staging") {
    const config = readConfig("/app/cfg/db.staging.ini");
    conStr = config.host + ":" + config.port;
    conUser = config.user;
    conPwd = readSecret("staging-db/pwd");
} else {
    const config = readConfig("/app/cfg/db.production.ini");
    conStr = config.host + ":" + config.port;
    conUser = config.user;
    conPwd = readSecret("production-db/pwd");
}

Deployment of this code might be daunting. We should update configuration files and secrets. Different code runs locally and on production; because of this, we might never run readSecret() function locally.

How can we improve the code?

  1. Get all the environment-specific configurations from the environment variables.
  2. Validate environment variables at the application startup.
  3. Declare types for the environment variables.
// Environment.d.ts file
declare global {
    namespace NodeJS {
        interface ProcessEnv {
            DB_HOST: string;
            DB_PORT: string;
            DB_USER: string;
            DB_PASS: string;
        }
    }
}

export {};

// Validate environment variables.
// Call the function at application startup

/**
 * Validate environment variable. Print an error message and throws an exception in case of failure.
 */
function validateEnvironment() {
    const envSchema = Joi.object({
        DB_HOST: Joi.string().required(),
        DB_PORT: Joi.string().required(),
        DB_USER: Joi.string().required(),
        DB_PASS: Joi.string().required(),
    }).unknown(true);

    const validationRes = envSchema.validate(env);
    if (validationRes.error) {
        console.log(validationRes.error.annotate());
        throw validationRes.error;
    }
}

// Use environment variables when needed.
import {env} from "process";

...

const conStr = env.DB_HOST + ":" + env.DB_PORT;
const conUser = env.DB_USER;
const conPwd = env.DB_PASS;

In the article, we consider the following deployment environments for the NodeJS application.

  1. Locally installed node program.
  2. Docker container in docker-compose stack running on the local computer or the CI server.
  3. Docker container in cloud container service. For example, Amazon ECS service.

We are going to use environment variables for deployment-specific application configuration. This approach is agnostic to application implementation details. And it is supported in all our deployment environments.

Configuring applications using environment variables is recommended by The Twelve-Factor App methodology. This approach is becoming mainstream for cloud platforms. Having settings in environment variables but not in local files allows us, for example, not to rebuild application docker image with updated application configuration when we rotate database password or change database server.

When we run applications locally, we can specify environment variables in the command line.

DB_HOST=localhost DB_PORT=3306 \
    DB_USER=user DB_PASS=pass node build/application.js

With many environment variables, this approach quickly becomes daunting. We can put all our variables into the .env file and load them using the dotenv package as a rescue plan. It is vital to preload it using the--require (-r) command line option but not in the application.

node -r dotenv/config build/application.js

Remember not to commit .env files to Git repository, they are needed to run the application on your computer but not in the production environment.

Docker-compose has support for .env files and/or environment can be configured in the docker-compose.yml file.

  service-name:
    container_name: service-name
    environment:
      - ALLOW_FROM_IP=127.0.0.1,::ffff:0:0/96
      - AWS_REGION=eu-west-1
      - LOG_LEVEL=debug
      - MONGO_URI=mongodb://mongo:27017/warehouse
      - PORT=3000
    image: 1234567890.dkr.ecr.eu-west-1.amazonaws.com/app-name:latest
    ports:
      - 3000:3000

Things to Remember

  • Use environment variables for environment-specific configuration.
  • Avoid dependencies on the environment in the application code (if possible). Application configuration should differ between deployments but not the application code.
  • Always validate configuration at application startup.

Robert C. Martin “Clean Architecture” p. 138 ↩︎