JSON Config Management: Multi-Env Merging, Zod Validation & Secrets
Last updated:
JSON config management means maintaining application configuration as JSON files — with environment-specific overrides, schema validation, and strict secrets separation — across dev, staging, and production environments. The base/override merge pattern uses Object.assign({},base,env) or deep merge for 2–5 files (base.json → staging.json → production.json → secrets loaded from env vars). JSON Schema $ref allows shared type definitions across all environment files from a single source.
This guide covers multi-environment config merging, type-safe config validation with Zod and JSON Schema, secrets management (never store in JSON files), config drift detection across environments, and runtime config reloading with chokidar. Every example includes TypeScript types.
Multi-Environment JSON Config: base.json + Overrides Pattern
The base/override pattern is the most maintainable approach to multi-environment JSON config: a base.json holds all keys with safe development defaults, and each environment file contains only the keys that differ. At startup, deep-merge the files in order — base first, then the environment override, then any local developer override (git-ignored). The merged result is validated before the application starts.
// ── Directory structure ───────────────────────────────────────────
// config/
// base.json ← all keys, development defaults
// staging.json ← staging overrides only
// production.json ← production overrides only
// local.json ← developer overrides (git-ignored)
// ── config/base.json ─────────────────────────────────────────────
{
"server": { "port": 3000, "host": "localhost", "timeout": 30000 },
"database": {
"host": "localhost",
"port": 5432,
"name": "myapp_dev",
"pool": { "min": 2, "max": 10 }
},
"redis": { "url": "redis://localhost:6379", "ttl": 3600 },
"logging": { "level": "debug", "format": "pretty" },
"features": { "betaSignup": true, "darkMode": false }
}
// ── config/production.json ───────────────────────────────────────
{
"server": { "host": "0.0.0.0", "timeout": 10000 },
"database": {
"host": "prod-db.internal",
"name": "myapp_prod",
"pool": { "min": 10, "max": 50 }
},
"logging": { "level": "warn", "format": "json" },
"features": { "betaSignup": false }
}
// ── config/loader.ts — deep merge at startup ──────────────────────
import fs from "fs";
import path from "path";
import merge from "lodash.merge"; // npm install lodash.merge
// or: import deepmerge from "deepmerge"; // npm install deepmerge
type Env = "development" | "staging" | "production";
function loadConfig() {
const env = (process.env.APP_ENV ?? "development") as Env;
const configDir = path.join(process.cwd(), "config");
const base = JSON.parse(
fs.readFileSync(path.join(configDir, "base.json"), "utf-8")
);
const envFile = path.join(configDir, `${env}.json`);
const envOverride = fs.existsSync(envFile)
? JSON.parse(fs.readFileSync(envFile, "utf-8"))
: {};
const localFile = path.join(configDir, "local.json");
const localOverride = fs.existsSync(localFile)
? JSON.parse(fs.readFileSync(localFile, "utf-8"))
: {};
// Deep merge: base ← env ← local
// Pass {} as first arg — lodash.merge mutates the first argument
return merge({}, base, envOverride, localOverride);
}
export const config = loadConfig();
// ── Usage across the codebase ─────────────────────────────────────
import { config } from "@/config/loader";
const db = new Pool({
host: config.database.host,
port: config.database.port,
database: config.database.name,
// password injected from env var — never in JSON
password: process.env.DB_PASSWORD,
min: config.database.pool.min,
max: config.database.pool.max,
});Keep environment override files small — only the keys that genuinely differ from base.json. A production.json with 3 keys is easier to audit than one that duplicates all 40 keys from base. Use APP_ENV (not NODE_ENV) for the environment selector so NODE_ENV=production can be set for Node.js runtime optimizations even in staging. Add config/local.json to .gitignore so developers can override values without polluting shared config files.
Type-Safe Config with Zod and JSON Schema
Validating the merged config object at startup catches missing keys, wrong types, and out-of-range values before any request is served. Zod provides TypeScript-first validation with inferred types — the same schema that validates also generates the TypeScript type, eliminating manual type declarations. JSON Schema with ajv is the right choice when the same schema must validate config in multiple languages or in CI scripts outside of TypeScript.
// ── Zod config validation (TypeScript) ───────────────────────────
import { z } from "zod"; // npm install zod
import merge from "lodash.merge";
import fs from "fs";
const ServerSchema = z.object({
port: z.number().int().min(1).max(65535),
host: z.string().min(1),
timeout: z.number().int().positive(),
});
const DatabaseSchema = z.object({
host: z.string().min(1),
port: z.number().int().default(5432),
name: z.string().min(1),
pool: z.object({
min: z.number().int().min(0),
max: z.number().int().min(1),
}),
});
const ConfigSchema = z.object({
server: ServerSchema,
database: DatabaseSchema,
redis: z.object({ url: z.string().url(), ttl: z.number().int() }),
logging: z.object({
level: z.enum(["debug", "info", "warn", "error"]),
format: z.enum(["pretty", "json"]),
}),
features: z.record(z.boolean()),
});
// Infer TypeScript type from the schema — no separate type declaration needed
export type AppConfig = z.infer<typeof ConfigSchema>;
function loadAndValidateConfig(): AppConfig {
const env = process.env.APP_ENV ?? "development";
const base = JSON.parse(fs.readFileSync("config/base.json", "utf-8"));
const envOv = fs.existsSync(`config/${env}.json`)
? JSON.parse(fs.readFileSync(`config/${env}.json`, "utf-8"))
: {};
const raw = merge({}, base, envOv);
const result = ConfigSchema.safeParse(raw);
if (!result.success) {
console.error("Config validation failed:");
console.error(result.error.format()); // structured error tree
process.exit(1); // fail fast at startup
}
return result.data;
}
export const config: AppConfig = loadAndValidateConfig();
// ── JSON Schema + ajv (multi-language compatible) ─────────────────
// config/schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$defs": {
"poolConfig": {
"type": "object",
"properties": {
"min": { "type": "integer", "minimum": 0 },
"max": { "type": "integer", "minimum": 1 }
},
"required": ["min", "max"],
"additionalProperties": false
}
},
"type": "object",
"properties": {
"server": {
"type": "object",
"properties": {
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
"host": { "type": "string", "minLength": 1 },
"timeout": { "type": "integer", "minimum": 1 }
},
"required": ["port", "host", "timeout"],
"additionalProperties": false
},
"database": {
"type": "object",
"properties": {
"host": { "type": "string" },
"port": { "type": "integer", "default": 5432 },
"name": { "type": "string" },
"pool": { "$ref": "#/$defs/poolConfig" }
},
"required": ["host", "name", "pool"],
"additionalProperties": false
}
},
"required": ["server", "database"],
"additionalProperties": false
}
// ── ajv validation ────────────────────────────────────────────────
import Ajv from "ajv";
import schema from "./config/schema.json";
const ajv = new Ajv({ allErrors: true, useDefaults: true });
const validate = ajv.compile(schema);
if (!validate(config)) {
throw new Error(`Config invalid: ${ajv.errorsText(validate.errors)}`);
}Use z.object({ ... }).strict() in Zod (or additionalProperties: false in JSON Schema) to reject keys not defined in the schema — a typo like "timout" instead of "timeout" would otherwise silently pass through and the real field would fall back to its default. The useDefaults: true ajv option applies JSON Schema default values during validation, so you can define defaults in the schema rather than duplicating them in base.json. See our JSON Schema validation guide for the full ajv setup.
Secrets Separation: What Must NOT Be in JSON Config
Secrets in JSON files are a persistent security failure mode: Git history is permanent, container images bake in every layer, and every developer with repo access sees every secret. The correct separation is absolute — JSON files hold non-sensitive settings, secrets live in environment variables or a managed secrets service injected at runtime.
// ── WRONG: secrets in JSON config ────────────────────────────────
// config/production.json ← NEVER do this
{
"database": {
"host": "prod-db.internal",
"password": "s3cur3P@ssw0rd", // ← exposed in Git forever
"ssl": true
},
"jwt": {
"secret": "my-jwt-signing-key-abc123" // ← anyone with repo access has it
},
"stripe": {
"secretKey": "sk_live_abc123xyz" // ← leaked to every developer
}
}
// ── RIGHT: inject secrets from environment variables ───────────────
// config/production.json ← only non-secret config
{
"database": { "host": "prod-db.internal", "port": 5432, "ssl": true },
"jwt": { "expiresIn": "1h", "algorithm": "HS256" },
"stripe": { "webhookEndpoint": "/api/stripe/webhook" }
}
// config/secrets.ts — load secrets at runtime from env vars
import { z } from "zod";
const SecretsSchema = z.object({
DB_PASSWORD: z.string().min(1),
JWT_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
REDIS_PASSWORD: z.string().optional(),
});
function loadSecrets() {
const result = SecretsSchema.safeParse(process.env);
if (!result.success) {
console.error("Missing required secrets:", result.error.format());
process.exit(1);
}
return result.data;
}
export const secrets = loadSecrets();
// config/index.ts — merge non-secret config + runtime secrets
import { config } from "./loader";
import { secrets } from "./secrets";
export const appConfig = {
...config,
database: {
...config.database,
password: secrets.DB_PASSWORD, // injected at runtime, never on disk
},
jwt: {
...config.jwt,
secret: secrets.JWT_SECRET,
},
};
// ── AWS Secrets Manager integration ──────────────────────────────
import {
SecretsManagerClient,
GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
const client = new SecretsManagerClient({ region: "us-east-1" });
async function getSecret(secretName: string): Promise<Record<string, string>> {
const response = await client.send(
new GetSecretValueCommand({ SecretId: secretName })
);
return JSON.parse(response.SecretString ?? "{}");
}
// At app startup — fetch once, cache in memory
const dbSecrets = await getSecret("myapp/production/database");
// { "password": "...", "replication_password": "..." }
// ── Checklist: config vs secrets ─────────────────────────────────
// CONFIG (safe in JSON): port, host, timeout, log level, feature flags,
// pool sizes, cache TTL, allowed origins, URLs
// SECRETS (env or vault): passwords, API keys, JWT secrets, private keys,
// OAuth secrets, webhook signing keys, tokensThe rule is simple: if rotating the value requires a Git commit, it is stored in the wrong place. Connection strings that embed credentials (like postgresql://user:password@host/db) are secrets — split them into a non-secret config portion (host, port, dbname) and a secret portion (user, password) injected at runtime. See our JSON security guide for additional attack vectors related to JSON config exposure.
Config Drift Detection Across Environments
Config drift is the gradual divergence of environment config files — a key added to staging but never added to production, a value type changed in development that breaks the production schema, or a required field missing from one environment. Left undetected, drift causes production incidents when a feature that worked in staging fails because its config key does not exist in production.
// ── Strategy 1: JSON Schema validation in CI ─────────────────────
// scripts/validate-configs.ts — run in CI before every deploy
import Ajv from "ajv";
import fs from "fs";
import merge from "lodash.merge";
import schema from "../config/schema.json";
const ajv = new Ajv({ allErrors: true, useDefaults: true });
const validate = ajv.compile(schema);
const envs = ["development", "staging", "production"];
let failed = false;
for (const env of envs) {
const base = JSON.parse(fs.readFileSync("config/base.json", "utf-8"));
const envOv = fs.existsSync(`config/${env}.json`)
? JSON.parse(fs.readFileSync(`config/${env}.json`, "utf-8"))
: {};
const merged = merge({}, base, envOv);
const valid = validate(merged);
if (!valid) {
console.error(`[FAIL] ${env}: ${ajv.errorsText(validate.errors)}`);
failed = true;
} else {
console.log(`[PASS] ${env}: config valid`);
}
}
if (failed) process.exit(1);
// ── Strategy 2: Key set diff — find keys in one env but not another
function flattenKeys(obj: Record<string, unknown>, prefix = ""): string[] {
return Object.entries(obj).flatMap(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
return value !== null && typeof value === "object" && !Array.isArray(value)
? flattenKeys(value as Record<string, unknown>, fullKey)
: [fullKey];
});
}
function detectDrift(
envA: string,
envB: string,
base: Record<string, unknown>
) {
const loadEnv = (env: string) => {
const override = fs.existsSync(`config/${env}.json`)
? JSON.parse(fs.readFileSync(`config/${env}.json`, "utf-8"))
: {};
return merge({}, base, override);
};
const configA = loadEnv(envA);
const configB = loadEnv(envB);
const keysA = new Set(flattenKeys(configA));
const keysB = new Set(flattenKeys(configB));
const onlyInA = [...keysA].filter((k) => !keysB.has(k));
const onlyInB = [...keysB].filter((k) => !keysA.has(k));
if (onlyInA.length || onlyInB.length) {
console.error(`Config drift between ${envA} and ${envB}:`);
if (onlyInA.length) console.error(` Only in ${envA}:`, onlyInA);
if (onlyInB.length) console.error(` Only in ${envB}:`, onlyInB);
return true;
}
return false;
}
const base = JSON.parse(fs.readFileSync("config/base.json", "utf-8"));
const hasDrift =
detectDrift("staging", "production", base) ||
detectDrift("development", "staging", base);
if (hasDrift) process.exit(1);
// ── Strategy 3: Zod strict parse catches extra keys ───────────────
// z.object({}).strict() rejects keys not in the schema
const StrictConfigSchema = ConfigSchema.strict();
for (const env of envs) {
const result = StrictConfigSchema.safeParse(mergedConfig(env));
if (!result.success) {
console.error(`${env} drift:`, result.error.format());
process.exit(1);
}
}Add the drift detection script as a CI step that runs before any deployment — not as a post-deploy health check. Catching drift at deploy time means the configuration never reaches a server; catching it at runtime means a deployment is already in progress. For large teams, also run the schema validation as a pre-commit hook (using Husky or lint-staged) so drift is caught before it even reaches a pull request review. See our JSON data validation guide for more schema enforcement patterns.
Deep Merge vs Shallow Merge for JSON Config Objects
The merge strategy is not a cosmetic choice — using the wrong one silently discards nested config keys, causing subtle production bugs. Shallow merge (Object.assign, spread syntax) replaces entire top-level values. Deep merge recursively merges nested objects at every level. For JSON config with nested structures (which all real configs have), deep merge is almost always correct.
// ── The problem: shallow merge drops nested keys ─────────────────
const base = {
database: { host: "localhost", port: 5432, pool: { min: 2, max: 10 } },
logging: { level: "debug", format: "pretty" },
};
const productionOverride = {
database: { host: "prod-db.internal" }, // only want to change host
logging: { level: "warn" }, // only want to change level
};
// ❌ Shallow merge — Object.assign or spread
const wrongResult = { ...base, ...productionOverride };
// {
// database: { host: "prod-db.internal" }, ← port and pool are GONE
// logging: { level: "warn" }, ← format is GONE
// }
// ❌ Object.assign — same problem, same shallow behavior
const alsoWrong = Object.assign({}, base, productionOverride);
// same as above — nested objects are replaced, not merged
// ── Deep merge with lodash.merge ─────────────────────────────────
import merge from "lodash.merge"; // npm install lodash.merge
// ✅ Correct: deep merge recurses into nested objects
const correct = merge({}, base, productionOverride);
// {
// database: { host: "prod-db.internal", port: 5432, pool: { min: 2, max: 10 } },
// logging: { level: "warn", format: "pretty" },
// }
// lodash.merge MUTATES the first argument — always pass {} as first
const a = { x: { y: 1 } };
const b = { x: { z: 2 } };
const merged = merge({}, a, b); // ✅ a is unchanged
// const bad = merge(a, b); // ← a is now { x: { y: 1, z: 2 } }
// ── Deep merge with deepmerge (immutable) ─────────────────────────
import deepmerge from "deepmerge"; // npm install deepmerge
const result = deepmerge(base, productionOverride);
// same correct result — base is not mutated
// Merge multiple: deepmerge.all([base, stagingOv, localOv])
const finalConfig = deepmerge.all([base, productionOverride, localOverride]);
// ── Array merge behavior — an important difference ─────────────────
// lodash.merge: merges arrays by index
merge({}, { tags: ["a", "b"] }, { tags: ["c"] });
// { tags: ["c", "b"] } ← index 0 overwritten, index 1 kept
// deepmerge: concatenates arrays by default
deepmerge({ tags: ["a", "b"] }, { tags: ["c"] });
// { tags: ["a", "b", "c"] } ← concatenated!
// deepmerge with array override (replace, not concat):
const overwriteMerge = (_dest: unknown[], src: unknown[]) => src;
deepmerge(base, productionOverride, { arrayMerge: overwriteMerge });
// arrays in override completely replace base arrays
// ── TypeScript: typed deep merge helper ──────────────────────────
import merge from "lodash.merge";
function deepMergeConfig<T extends Record<string, unknown>>(
base: T,
...overrides: Partial<T>[]
): T {
return merge({} as T, base, ...overrides);
}Array merge behavior is the one area where lodash.merge and deepmerge differ significantly. For config objects, arrays typically represent lists like allowed origins or feature flag names — you almost always want the override array to replace the base array, not concatenate or index-merge. Use deepmerge with the arrayMerge: (_dest, src) => src option, or avoid arrays entirely in JSON config in favor of objects with named keys. See our TypeScript JSON types guide for how to type config objects correctly.
Runtime Config Reload with chokidar
Hot config reload lets long-running Node.js servers pick up config changes — a feature flag flip, a log level change, a timeout adjustment — without a process restart. chokidar watches filesystem events and triggers a reload callback. The key design constraint: export a getConfig() getter rather than a plain object reference, so all callers automatically see the new config after reload.
// npm install chokidar lodash.merge zod
import chokidar from "chokidar";
import fs from "fs";
import path from "path";
import merge from "lodash.merge";
import { ConfigSchema, type AppConfig } from "./schema";
const CONFIG_DIR = path.join(process.cwd(), "config");
const ENV = process.env.APP_ENV ?? "development";
function readAndMerge(): AppConfig {
const base = JSON.parse(
fs.readFileSync(path.join(CONFIG_DIR, "base.json"), "utf-8")
);
const envOv = fs.existsSync(path.join(CONFIG_DIR, `${ENV}.json`))
? JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, `${ENV}.json`), "utf-8"))
: {};
const raw = merge({}, base, envOv);
const result = ConfigSchema.safeParse(raw);
if (!result.success) throw new Error(result.error.message);
return result.data;
}
// ── Singleton with getter pattern ─────────────────────────────────
let _config: AppConfig = readAndMerge();
let _previousConfig: AppConfig = _config;
// Export getter — NOT the object directly
// Consumers call getConfig() each time, so they always get current value
export function getConfig(): AppConfig {
return _config;
}
// ── File watcher ─────────────────────────────────────────────────
const watchPaths = [
path.join(CONFIG_DIR, "base.json"),
path.join(CONFIG_DIR, `${ENV}.json`),
].filter(fs.existsSync);
const watcher = chokidar.watch(watchPaths, {
persistent: true,
ignoreInitial: true, // don't fire on startup
awaitWriteFinish: {
stabilityThreshold: 300, // wait 300ms after last write event
pollInterval: 100,
},
});
watcher.on("change", (filePath) => {
console.log(`Config file changed: ${path.basename(filePath)} — reloading`);
try {
const newConfig = readAndMerge();
_previousConfig = _config; // keep previous as fallback
_config = newConfig;
console.log("Config reloaded successfully");
} catch (err) {
// Validation failed — keep previous config running
console.error("Config reload failed — keeping previous config:", err);
_config = _previousConfig; // rollback
}
});
watcher.on("error", (err) => {
console.error("Config watcher error:", err);
});
// ── Usage in Express route handlers ──────────────────────────────
import { getConfig } from "@/config/loader";
import express from "express";
const app = express();
app.get("/api/items", async (req, res) => {
const config = getConfig(); // always fresh
const timeout = config.server.timeout;
// feature flags from config — no restart needed to toggle
if (!getConfig().features.betaSignup) {
return res.status(404).json({ error: "Not available" });
}
// ...handler logic
});
// ── Graceful shutdown ─────────────────────────────────────────────
process.on("SIGTERM", async () => {
await watcher.close();
process.exit(0);
});The awaitWriteFinish option is critical — without it, chokidar fires on the first byte written, and the reload reads a partially written file. The stabilityThreshold: 300 waits until no write events have occurred for 300ms before triggering, ensuring the file is fully flushed to disk. Hot reload is most useful for feature flags, log levels, and timeout values. Never hot-reload secrets from files — re-fetch from the secrets manager on a timer instead, and rotate them through the secrets manager UI rather than file changes.
JSON Config in Docker, Kubernetes, and CI/CD Pipelines
Container environments add a layer between the config file on disk and the application runtime. The base/override pattern maps cleanly to Docker and Kubernetes primitives — non-secret config lives in ConfigMaps or baked into the image, secrets live in Kubernetes Secrets or a secrets manager, and the environment selector (APP_ENV) comes from a Deployment environment variable.
# ── Dockerfile: bake non-secret config into image ────────────────
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/config ./config # ← non-secret JSON config
COPY --from=builder /app/package.json .
RUN npm ci --omit=dev
# APP_ENV selects the override file at runtime
ENV APP_ENV=production
CMD ["node", "dist/server.js"]
# ── docker run: inject secrets as env vars ────────────────────────
# docker run # -e APP_ENV=production # -e DB_PASSWORD="$DB_PASSWORD" # -e JWT_SECRET="$JWT_SECRET" # myapp:latest
# ── Kubernetes ConfigMap (non-secret config) ──────────────────────
# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: myapp-config
data:
production.json: |
{
"server": { "host": "0.0.0.0", "timeout": 10000 },
"database": {
"host": "prod-db.internal",
"pool": { "min": 10, "max": 50 }
},
"logging": { "level": "warn", "format": "json" }
}
# ── Kubernetes Deployment: mount ConfigMap + inject secrets ───────
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: myapp
image: myapp:latest
env:
- name: APP_ENV
value: "production"
- name: DB_PASSWORD # from Kubernetes Secret
valueFrom:
secretKeyRef:
name: myapp-secrets
key: db-password
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: myapp-secrets
key: jwt-secret
volumeMounts:
- name: config-volume
mountPath: /app/config/production.json
subPath: production.json # mount single file, not whole dir
volumes:
- name: config-volume
configMap:
name: myapp-config
# ── GitHub Actions CI/CD: validate config before deploy ───────────
# .github/workflows/deploy.yml
jobs:
validate-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- run: npm ci
# Validate all environment configs before deploying
- name: Validate config schemas
run: npx ts-node scripts/validate-configs.ts
# Detect drift between environments
- name: Check config drift
run: npx ts-node scripts/detect-drift.ts
# Deploy only if config is valid
- name: Deploy to production
if: success()
run: npm run deployIn Kubernetes, prefer mounting ConfigMaps as files over injecting them as environment variables when the config is large — environment variables have a practical limit around 32KB total and are harder to structure. Use subPath when mounting a single file from a ConfigMap so the mount does not replace the entire /app/config/ directory (which would hide base.json). For secrets, prefer the secrets manager pattern (AWS Secrets Manager, HashiCorp Vault with the Vault Agent sidecar) over Kubernetes Secrets when operating at scale — Kubernetes Secrets are base64-encoded (not encrypted) by default and require additional etcd encryption configuration.
Key Terms
- config drift
- The gradual divergence of configuration files across environments where staging, production, and development no longer share the same key structure or schema. Drift occurs when a developer adds a config key to staging for a new feature but forgets to add it (with a production-appropriate value) to production, or when a type changes in one environment without being updated everywhere. Config drift causes production incidents when code that worked in staging fails because a required config key is absent. Detection requires validating all environment configs against the same JSON Schema in CI, or running a key set diff script that compares the flattened key sets of each environment's merged config.
- base/override pattern
- A JSON config management strategy where a
base.jsonfile contains all configuration keys with safe development defaults, and environment-specific files (staging.json,production.json) contain only the keys that differ from the base. At application startup, the files are deep-merged in order — base first, then the environment override — producing a complete merged config object. Override files are intentionally small (typically 5–15% of total config keys), making them easy to audit. The pattern is the JSON equivalent of how frameworks like Spring Boot and Rails handle environment-specific configuration, adapted for JSON files and Node.js applications. - deep merge
- A merge strategy that recursively combines nested objects rather than replacing them at the top level. Given
{ "db": { "host": "localhost", "port": 5432 } }and override{ "db": { "host": "prod.internal" } }, deep merge produces{ "db": { "host": "prod.internal", "port": 5432 } }— preserving the non-overriddenportkey. Shallow merge (Object.assign, spread syntax) would produce{ "db": { "host": "prod.internal" } }— losingport. Deep merge is implemented bylodash.merge(mutates first argument, pass{}as target) anddeepmerge(immutable). Arrays in deep merge require explicit strategy: lodash merges by index, deepmerge concatenates by default — both behaviors are usually wrong for config; use anarrayMerge: (_, src) => srcoverride to replace arrays. - secrets manager
- A dedicated service for storing, rotating, and auditing access to sensitive credentials — as opposed to storing secrets in files, environment variables, or version control. Examples: AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager, Azure Key Vault, Doppler. A secrets manager provides encrypted storage at rest, fine-grained access control (only specific IAM roles or service accounts can read a secret), automatic rotation (new database password generated and propagated without manual intervention), access audit logs (who read which secret and when), and versioning (roll back to a previous secret value if rotation fails). Applications fetch secrets at startup via the secrets manager SDK, cache them in memory, and periodically re-fetch to pick up rotations. The secrets manager is the correct home for database passwords, API keys, JWT signing secrets, and TLS private keys.
- JSON Schema $ref
- A JSON Schema keyword that references another schema definition by URI or by a JSON Pointer within the same document. In JSON Schema Draft-07 and later, reusable sub-schemas are defined in a
$defsblock and referenced with{"$ref": "#/$defs/myType"}. For config management,$refenables a sharedpoolConfigsub-schema referenced from bothdatabase.poolandredis.pool, ensuring both validate against the same type definition.$refalso allows splitting a large schema across multiple files —{"$ref": "./database-schema.json"}— which ajv resolves when configured withaddSchema(). This is the JSON Schema equivalent of a TypeScripttypealias reused across multiple interfaces. - hot reload
- The ability to update application behavior at runtime without restarting the process. For JSON config, hot reload means watching config files for changes with a library like chokidar, re-reading and re-validating the merged config on change, and updating an in-memory config object so subsequent requests use the new values. Hot reload is most valuable for feature flags (toggle without deployment), log levels (increase verbosity during an incident), and timeout adjustments (relax limits during high load). It requires a getter pattern (
getConfig()) rather than a module-level object export — modules that imported the old object reference before the reload would otherwise hold a stale value. Hot reload should not be used for secrets — rotating secrets should go through a secrets manager, not a file change. - chokidar
- A Node.js filesystem watcher library (
npm install chokidar) that provides a cross-platform, reliable API for watching files and directories for changes. It wraps the nativefs.watchandfs.watchFileAPIs with fallback polling for environments where native watchers are unreliable (Docker volumes, network filesystems, WSL). Key API:chokidar.watch(paths, options)returns a watcher that emitsadd,change,unlink, anderrorevents. TheawaitWriteFinishoption prevents reading partially written files by waiting until no write events have occurred for a configurable stability threshold (typically 200–500ms). Chokidar is the filesystem watcher used by Webpack, Vite, and Jest — it is a stable, well-tested dependency appropriate for production config reloading.
FAQ
How do I manage JSON config across dev, staging, and production?
Use the base/override pattern: a base.json holds all keys with development defaults; environment files (staging.json, production.json) hold only the keys that differ. At startup, deep-merge the files in order using lodash.merge({},base,envOverride) and validate the merged result with Zod or JSON Schema before serving any requests. Use APP_ENV (not NODE_ENV) to select the environment file — NODE_ENV should stay "production" for Node runtime optimizations even in staging. Store the validated config in a module-level singleton and export a getConfig() function so hot reload can update the value without breaking existing import references. Add config/local.json to .gitignore so developers can override values without committing to shared config files. Run schema validation against all environment configs in CI before every deployment to catch drift early.
How do I validate JSON config files with JSON Schema?
Write a JSON Schema with additionalProperties: false at every level and a required array for every mandatory field. Validate the merged config using ajv: import Ajv from "ajv"; const ajv = new Ajv({ allErrors: true, useDefaults: true }); const validate = ajv.compile(schema); if (!validate(config)) throw new Error(ajv.errorsText(validate.errors)). The useDefaults: true option applies JSON Schema default values during validation, reducing duplication between the schema and base.json. Use $ref to share sub-schemas across environments — define a poolConfig once in $defs and reference it from both database and Redis config. Run validation in CI against every environment's merged config file, not just the one being deployed, to catch cross-environment drift. The same schema can be used in a pre-commit hook (via Husky) to validate config before changes are committed. See our JSON Schema validation guide for the full ajv setup including custom keywords.
What should I never put in a JSON config file?
Never store secrets in JSON config files: database passwords, API keys, JWT signing secrets, OAuth client secrets, private keys, webhook signing keys, encryption keys, or any credential that grants access to a system or data. JSON files are tracked in version control (Git history is permanent), baked into container images pushed to registries, included in deployment archives, and readable by anyone with filesystem access to the server. Secrets belong in environment variables (process.env.DB_PASSWORD), a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager), or a platform secrets store (Kubernetes Secrets, Vercel environment variables). The test: if rotating the value requires a Git commit, it is stored in the wrong place. Connection strings that embed credentials (postgresql://user:password@host/db) are also secrets — split them into a non-secret host/port/name portion in JSON and a credentials portion in environment variables. Load secrets at runtime and merge them into the config object in memory; never write the combined object to disk. See our JSON security guide for related attack vectors.
How do I merge JSON config files with environment-specific overrides?
Use deep merge, not Object.assign() or spread syntax. Shallow merge replaces entire top-level keys — if base.json has database: { host, port, pool } and the production override has only database: { host }, shallow merge produces database: { host } and loses port and pool. Deep merge recurses into nested objects and merges at every level. Two libraries: lodash.merge (npm install lodash.merge) mutates the first argument — always pass {} as the first arg: merge({},base,envOverride,localOverride). deepmerge (npm install deepmerge) is immutable: deepmerge.all([base, envOverride, localOverride]). For array values, use arrayMerge: (_dest, src) => src in deepmerge so override arrays replace base arrays instead of concatenating. Inject secrets from process.env after the file merge, adding them to the in-memory config object but never writing the combined result back to a file.
How do I detect config drift between environments?
Use three complementary strategies in CI: (1) JSON Schema validation — validate every environment's merged config against the same schema. A key present in one environment but not defined in the schema fails validation; a key required by the schema but absent from any environment also fails. Run npx ts-node scripts/validate-configs.ts in CI before every deployment. (2) Key set diff — flatten each environment's merged config to dot-notation keys and compare the sets: const onlyInStaging = [...stagingKeys].filter(k => !prodKeys.has(k)). Any asymmetry indicates drift. (3) Zod strict parse — use z.object({ ... }).strict() which rejects keys not in the schema, catching extra undocumented keys in any environment. Add the drift detection script as a blocking CI step — fail the build if drift is detected. For large teams, also run validation as a pre-commit hook so drift is caught before it enters a pull request. See our JSON data validation guide for more enforcement patterns.
How do I hot-reload JSON config without restarting Node.js?
Use chokidar to watch config files for filesystem changes and reload the merged, validated config object on each change. Install: npm install chokidar. The pattern: watch the config files with chokidar.watch(watchPaths, { awaitWriteFinish: { stabilityThreshold: 300 } }); on the "change" event, read and merge the files again, validate with Zod or ajv, and replace the module-level config variable — keeping the previous config as a fallback if validation fails. The critical design detail: export a getConfig() function, not the config object directly. Modules that imported the config object before a reload hold a reference to the old object and will never see the new values. With a getter, every call gets the current value. The awaitWriteFinish option prevents reading a partially written file — without it, chokidar fires on the first byte written and the reload reads a truncated JSON file. Hot reload is appropriate for feature flags and log levels; use a scheduled re-fetch from a secrets manager for secrets rotation.
How do I use JSON config in Docker containers?
Three patterns: (1) Bake into image — COPY config/ /app/config/ in the Dockerfile. All non-secret environment config files are included in the image; the runtime environment is selected via APP_ENV environment variable. This is the simplest approach and works well when config changes require a new deployment anyway. (2) Volume mount — mount the config directory at runtime: docker run -v /host/config:/app/config myimage. Useful when config changes frequently independently of code changes. (3) Kubernetes ConfigMap — store non-secret config in a ConfigMap and mount it as a file into the container at /app/config/production.json using subPath so base.json is not hidden. For secrets, use Kubernetes Secrets with secretKeyRef to inject as environment variables, or use a Vault Agent sidecar to write secrets to an in-memory volume. Never COPY files containing real secrets into a Docker image — every image layer is permanent and pushed to registries accessible to all team members.
What is the difference between config and secrets in JSON?
Config is non-sensitive application settings that control behavior: server port, log level, feature flags, cache TTL, timeout values, retry counts, allowed CORS origins, pagination limits, pool sizes. Config can live in JSON files, version control, and container images because knowing these values does not enable an attacker to access your systems or data. Secrets are credentials that authenticate to systems: database passwords, API keys, JWT signing secrets, OAuth client secrets, TLS private keys, encryption keys, webhook signing secrets. A leaked secret lets an attacker impersonate your service or access your data. A leaked config value reveals application behavior but grants no access. The practical test: if an attacker who knows the value can authenticate to any system or decrypt any data, it is a secret. A second test: if rotating the value requires a Git commit, it is in the wrong place. Connection strings that embed both config (host, port, database name) and secret (username, password) should be split — store the non-secret parts in JSON config and inject credentials from environment variables or a secrets manager at runtime. The merged result lives only in memory and is never written back to disk.
Further reading and primary sources
- lodash.merge documentation — Lodash merge() function — deep merge for plain objects, the standard for JSON config merging in Node.js
- deepmerge npm package — Immutable deep merge library with configurable array merge strategy
- Zod documentation — TypeScript-first schema validation library — define config schema once, get TypeScript types and runtime validation
- chokidar documentation — Minimal and efficient cross-platform file watching library used by Webpack, Vite, and Jest
- AWS Secrets Manager — AWS managed secrets storage with automatic rotation, fine-grained IAM access, and audit logging
- HashiCorp Vault — Open-source secrets management platform with dynamic secrets, leasing, and renewal