Node.js Configuration Made Simple: Wrangling Environment Variables
Introduction
Managing configuration settings in Node.js applications is a common challenge, especially when dealing with sensitive information like API keys, database credentials, and server configurations. Keeping such information secure while ensuring it remains accessible to the application is crucial. A common approach is to store sensitive data in environment variables and abstract other configuration settings into files such as `.yaml` or `.json`.
In this article, we explore a simplified yet effective approach to handling configuration in Node.js by using environment variables and configuration files. We’ll demonstrate how to securely inject environment variables into a YAML configuration file, and extend this solution to handle JSON configurations. To add robustness, we’ll validate our configuration data using TypeScript and Zod, ensuring type safety and error checking.
Using YAML for Configuration
We create a YAML configuration file to store server settings, Redis credentials, and AWS S3 details. These settings will be placeholders, with sensitive data injected via environment variables. Here’s an example of what the YAML file might look like:
# config.yaml
server:
port: 3000
redis:
host: ${REDIS_HOST}
port: ${REDIS_PORT}
aws:
s3:
region: ${AWS_S3_REGION}
The placeholders such as `${REDIS_HOST}` refer to environment variables that store sensitive data. We aim to parse this file and replace these placeholders with actual environmental values, ensuring sensitive data is kept separate from source control.
# .env
REDIS_HOST="xxxxxxxxxx"
REDIS_PORT="xxxxxxxxxx"
AWS_S3_REGION="xxxxxxxxxx"
Reading and Parsing the YAML File
We can define a function to read and parse the YAML file. We swap out the placeholders with environment variable values by reading the file contents, stringifying them, and using a regular expression to find and replace placeholders.
import fs from "node:fs/promises";
import yaml from "yaml";
async function getConfig(file: string) {
const fileContent = await fs.readFile(file, { encoding: "utf-8" });
const config = yaml.parse(fileContent);
const resolvedFile = JSON.stringify(config).replace(
/\$\{(\w+)\}/g,
(_, envVar) => {
return process.env[envVar];
},
);
return JSON.parse(resolvedFile);
}
await function main() {
const config = await getConfig("./config.yaml");
console.log(config.server.port);
console.log(config.redis.host);
console.log(config.redis.port);
console.log(config.aws.s3.region);
};
main().catch((err) => {
console.error(err);
process.exit(1);
});
Extending to JSON Configuration
The same logic can be applied to JSON files, given the similarity between YAML and JSON in structure. To support both file types, we can extend our code to handle JSON configurations by detecting the file type and parsing it accordingly.
import path from "node:path";
import fs from "node:fs/promises";
import "dotenv/config";
import yaml from "yaml";
function getFileType(filePath: string) {
const extension = path.extname(filePath);
return extension.slice(1).toLowerCase();
}
async function getConfig(filePath: string) {
const file = await fs.readFile(filePath, { encoding: "utf-8" });
let config: string;
const fileType = getFileType(filePath);
switch (fileType) {
case "json":
config = JSON.parse(file);
break;
case "yaml":
config = yaml.parse(file);
break;
default:
throw new Error(`Unsupported file type: ${fileType}`);
}
const resolvedFile = JSON.stringify(config).replace(
/\$\{(\w+)\}/g,
(_, envVar) => {
return process.env[envVar];
},
);
return JSON.parse(resolvedFile);
}
Adding Validation with Zod
TypeScript adds type safety but can be enhanced with schema validation using libraries like Zod. By defining a schema, we can validate that our configuration data is structured correctly and avoid potential runtime errors.
import { z } from "zod";
// Define schema for validation
const envSchema = z.object({
server: z.object({
port: z.coerce.number(),
}),
redis: z.object({
host: z.string(),
port: z.coerce.number(),
}),
aws: z.object({
s3: z.object({
region: z.string(),
}),
}),
});
async function getConfig<T>(filePath: string, schema: z.Schema<T>) {
// Reading and resolving config logic...
return schema.parse(JSON.parse(resolvedFile)); // Validate parsed data
}
async function main() {
const config = await getConfig("./config.json", envSchema);
console.log(config.server.port);
console.log(config.redis.host);
console.log(config.redis.port);
console.log(config.aws.s3.region);
}
Conclusion
Managing environment variables in configuration files can be tricky, but leveraging `.yaml`, and `.json` file types, and environment variable injection provides a flexible and secure approach. TypeScript and Zod, ensure that configurations are robust, type-safe, and error-resistant.