Clean configuration in NodeJs

Monday, August 30, 2021

Your application's configuration includes everything that is likely to vary between deployments. Below is a trick I use to keep configuration type-safe and simple.

Let's get hands-on right away with the following basic NodeJs server and PostgreSQL database.

// src/main.ts
import Fastify from "fastify";
import pino, { Logger } from "pino";
import { createPool, DatabasePoolConnectionType, sql } from "slonik";

const HELLO_NAME = 'Demo';

(async function main() {
  const logger = pino({
    level: "info",
  });
  const database = createDatabasePool({
    host: "localhost",
    port: 5432,
    name: "demo",
    user: "usr",
    password: "p4ssw0rd",
  });
  const server = createServer({ logger, database });

  await server.listen(8080, "0.0.0.0");
})();

type DatabaseInit = {
  user: string;
  password: string;
  host: string;
  port: number;
  name: string;
};

function createDatabasePool({ user, password, host, port, name }: DatabaseInit) {
  const userEncoded = encodeURIComponent(user);
  const passwordEncoded = encodeURIComponent(password);
  const connectionString = `postgres://${userEncoded}:${passwordEncoded}@${host}:${port}/${name}`;
  return createPool(connectionString);
}

type ServerInit = {
  logger: Logger;
  database: DatabasePoolConnectionType;
}

function createServer({ logger, database }: ServerInit) {
  const app = Fastify({ logger });

  app.get("/now", async (_request, reply) => {
    const time = await database.oneFirst(sql`SELECT NOW()`);
    reply.code(200).header('Content-Type', 'application/json').send({ time })
  });

  app.get("/hello", (_request, reply) => {
    reply.code(200).header('Content-Type', 'text/plain').send(`hello from ${HELLO_NAME}`);
  });

  return app;
}

You can run the program by executing the following commands:

docker run --rm --detach \
  --name demo \
  -e POSTGRES_DB=demo \
  -e POSTGRES_USER=dev-usr \
  -e POSTGRES_PASSWORD=dev-p4ssw0rd \
  -p 5432:5432 \
  postgres:12.1

npx ts-node src/main.ts

Standard configuration

These hardcoded configuration values are impossible to change across environments. To improve this, you can add node-config. This library combines the best of both worlds between environment variables and configuration files. I also like to include js-yaml as it's a bit more human-friendly than json.

yarn add config js-yaml

Start by adding these configuration files for your production and development environment:

# config/default.yaml
logger:
  level: "info"
server:
  port: 8080
database:
  port: 5432
features:
  helloName: "Demo"

# config/production.yaml
database:
  host: "192.168.100.2"
	user: "prd-usr"
  password: "prd-p4ssw0rd"

# config/development.yaml
database:
  host: "localhost"
	user: "usr"
  password: "p4ssw0rd"

# config/custom-environment-variables.yaml
database:
  host: DATABASE_HOST
  user: DATABASE_USER
  password: DATABASE_PASSWORD

Below you can see the code with standard usage of node-config, though keep on reading below to see how we can do this much cleaner.

// src/main.ts
import Fastify from "fastify";
import pino, { Logger } from "pino";
import { createPool, DatabasePoolConnectionType, sql } from "slonik";
import config from "config";

const SERVER_PORT = config.get<number>("server.port");
const HELLO_NAME = config.get<string>("features.helloName");

(async function main() {
  const logger = pino({
    level: config.get("logger.level"),
  });
  const database = createDatabasePool({
    host: config.get("database.host"),
    port: config.get("database.port"),
    name: config.get("database.name"),
    user: config.get("database.user"),
    password: config.get("database.password"),
  });
  const server = createServer({ logger, database });

  await server.listen(SERVER_PORT, "0.0.0.0");
})();

// Omitted createDatabasePool and createServer for brevity

Type-safe and simple configuration

You might notice that it's quite error-prone to get each value with a file path string. Besides that all these hardcoded strings make your code harder to read. So let's improve this by adding one additional dependency:

yarn add zod 

Zod is a library for TypeScript-first schema validation. It's often used to validate form data or HTTP requests but today you will use it to make configuration a breeze.

The trick is to simply create a new file called config.ts in which you define a configuration schema with Zod and afterwards read and parse your whole configuration file.

// src/config.ts
import configFiles from "config";
import * as z from "zod";

const configSchema = z.object({
  server: z.object({
    port: z.number().min(0).max(65535),
  }),
  database: z.object({
    host: z.string(),
    port: z.number().min(0).max(65535),
    name: z.string(),
    user: z.string(),
    password: z.string(),
  }),
  logger: z.object({
    level: z.enum(["error", "info", "debug" ]),
  }),
  features: z.object({
    helloName: z.string(),
  }),
});

export type Config = z.infer<typeof configSchema>;
export const config: Config = configSchema.parse(configFiles.util.toObject());

Afterwards using your configuration becomes much simpler:

// src/main.ts
import Fastify from "fastify";
import pino, { Logger } from "pino";
import { createPool, DatabasePoolConnectionType, sql } from "slonik";
import { config } from "./config";

const HELLO_NAME = config.features.helloName;

(async function main() {
  const logger = pino(config.logger);
  const database = createDatabasePool(config.database);
  const server = createServer({ logger, database });

  await server.listen(config.server.port, "0.0.0.0");
})();

// Omitted createDatabasePool and createServer for brevity.

The result is much cleaner, type-safe and adding refinements avoids misconfiguration. The underlying configuration library also becomes an implementation detail which allows you to change it without modifications across your whole codebase.

One additional benefit is that parsing and validating the whole configuration at bootstrap allows you to fail early. For example, misconfigured rolling deployments on Kubernetes will fail at startup and won't get past the readiness probe.

That's it! Quite a simple trick but I would definitely recommend to try it.

You can try out the program of this blog by checking out this repository.

Sign up for the newsletter and I'll email you a fresh batch of insights once every while.

✉️