The following files are looked up and merged (the latest take precedence; files of the same directory are merged in name order):
- vendor:
config.*in the application directory; - system:
/etc/my-application/config.*; - global:
~/.config/my-application/config.*(or$XDG_CONFIG_HOME/my-application/config.*if set; on Windows:%APPDATA%\my-application\config.*); - local:
/.my-application.*down to./.my-application.*in the current working directory; - env: environment variables prefixed with the app name (see below);
Note: the local config is relative to the current working directory and only makes sense for CLIs.
import { load as loadConfig } from "app-conf";
const config = await loadConfig({
appName: "my-application",
// this is the directory where the vendor conf is stored
//
// vendor config will not be loaded if not defined
appDir: new URL(".", import.meta.url).pathname,
// default config values
defaults: {},
// which types of config should be loaded
entries: ["vendor", "system", "global", "local", "env"],
// whether to ignore unknown file formats instead of throwing
ignoreUnknownFormats: false,
// prefix for environment variable overrides
//
// defaults to the app name uppercased with non-alphanumeric characters
// replaced by underscores, e.g. "my-application" → "MY_APPLICATION_"
//
// set to false to disable env var overrides entirely
//envPrefix: "MY_APPLICATION_",
});
console.log(config);Deprecated:
loadConfig(appName, opts)(two-argument form) still works but is deprecated; passappNameinside the options object instead.
CommonJS
const { load: loadConfig } = require("app-conf");
loadConfig({
appName: "my-application",
appDir: __dirname,
defaults: {},
entries: ["vendor", "system", "global", "local", "env"],
ignoreUnknownFormats: false,
}).then((config) => {
console.log(config);
});Relative paths, string values starting by ./ or ../, are automatically
resolved from the config file directory.
Paths relative to the home directory, string values starting by ~/, are also
automatically resolved.
JSON format is supported natively but you may install the following packages to have additional features:
- smol-toml: to support TOML files;
- ini: to support INI files;
- js-yaml (≥ 4): to support YAML files;
- json5: to support advanced JSON files;
- strip-json-comments: to support comments in JSON files.
For formats not supported out of the box, pass a serializers array. Each
entry needs a test(path) function and a parse(content) function. Custom
serializers are checked before built-ins, so they can also override an
existing format.
import { load } from "app-conf";
import { parse as parseCson } from "cson-parse";
const config = await load("my-application", {
serializers: [
{
test: (path) => /\.cson$/i.test(path),
parse: (content) => parseCson(content),
},
],
});The serializers option is also accepted by watch() and parse().
Environment variables are the highest-precedence source and always win over file-based config.
The prefix is derived from the app name by default (my-application →
MY_APPLICATION_). Use __ to separate nested keys:
MY_APPLICATION_port=8080 → { port: "8080" }
MY_APPLICATION_port=json:8080 → { port: 8080 }
MY_APPLICATION_database__host=db → { database: { host: "db" } }
MY_APPLICATION_feature__enabled=json:true → { feature: { enabled: true } }
MY_APPLICATION_tags=json:["a","b"] → { tags: ["a", "b"] }
Values are strings by default. Prefix a value with json: to parse it as
JSON — numbers, booleans, arrays, and objects are all supported. A malformed
json: value throws rather than silently falling back to a string.
Key segments are used as-is — their case is not transformed. Use the exact casing your config schema expects:
MY_APPLICATION_port=8080 → { port: "8080" } ✓ lowercase key
MY_APPLICATION_Port=8080 → { Port: "8080" } different key
This method reload the configuration every time it might have changed.
const watchConfig = require("app-conf").watch;
const stopWatching = await watchConfig(
{
// contrary to `load`, this is part of the options
appName: "my-application",
// if set to true the configuration will be loaded before waiting for
// changes
//
// in that case, the returned promise will reject if the initial load
// failed, or will resolve after the callback has been called with the
// initial configuration
//
// because the async call to `watchConfig()` will not have returned yet,
// `stopWatching()` will not be available in this first callback call
initialLoad: false,
// all other options are passed to load()
},
(error, config) => {
if (error !== undefined) {
console.warn("loading config has failed");
// we might not want to retry on changes
stopWatching();
return;
}
console.log("config has been loaded", config);
},
);Note: the vendor config IS NOT watched, but it's loaded as expected.
Low level function which parses a file using app-conf logic, automatically handling formats and resolving paths.
const parseConfig = require("app-conf").parse;
const config = await parseConfig("config.toml");A basic CLI is available to show the config:
> ./node_modules/.bin/app-conf
Usage: app-conf [--json | -j] [--watch | -w] [--env-prefix <prefix> | --no-env] [--ignore-unknown-formats] [--sources | -p <path>...] <appName> [<appDir>]
app-conf (--set <path>=<value> | --unset <path>)... <appName>
> ./node_modules/.bin/app-conf my-app .
Use --sources to list, instead of the config, the files and env vars it is loaded from, in merge order, with the files that fail to load and why (env var values are not printed, they may be secrets):
> ./node_modules/.bin/app-conf --sources my-app .
vendor /app/config.json
global /home/me/.config/my-app/config.json
global /home/me/.config/my-app/config.json.bak (error: unknown format)
local /home/me/project/.my-app.json (error: Unexpected end of JSON input)
env MY_APP_db__host
The -p flag accepts dot-notation paths (e.g. -p database.host) to print a single value or, when repeated, an object keyed by each path.
Use --env-prefix <prefix> to override the derived env var prefix, or --no-env to disable env var overrides entirely.
Like load(), the CLI fails on config files in an unknown format (e.g. config.json.bak, or config.toml without smol-toml installed); pass --ignore-unknown-formats if the application uses the ignoreUnknownFormats option.
Use --set and --unset to override values of the existing config files, without editing them, and to remove these overrides:
> ./node_modules/.bin/app-conf --set db.port=json:5433 --set db.host=db2 my-app
written /home/me/.config/my-app/config.~override.json
> ./node_modules/.bin/app-conf --unset db.port --unset db.host my-app
deleted /home/me/.config/my-app/config.~override.json
- Values are strings, prefix them with
json:for other types, like env vars. - They are written in the
config.~override.jsonfile of the global config, whose name sorts after the other files of this directory so it takes precedence over them; local config and env vars still take precedence over it. - Objects left empty by
--unsetare removed, and the file is deleted when empty. - Like any config value: arrays are merged by index (
--set hosts=json:["x"]over["a", "b"]gives["x", "b"]) and relative paths are resolved from the file's directory. - With zsh's
EXTENDED_GLOBoption,~is a pattern operator: quote the file name.
Note: To ensure the configuration is parsed the same way as your application (e.g. optional formats), this command should be run from your application directory and not from a global install.
app-conf is built around a few deliberate principles.
Each source has a distinct audience. Sources are loaded in order and merged so that later layers override earlier ones:
- vendor: the application's own bundled defaults
- system (
/etc/<app>): machine-wide settings, set by a sysadmin or service deployment - global (
~/.config/<app>): per-user settings, or user-level service config - local (
./<app>.*): directory-sensitive config for tools like CLIs - env: runtime overrides — secrets, environment-specific values, feature flags
Default values belong in vendor config, not in code. Hardcoding defaults directly in application code hides them from operators and makes them impossible to override without patching the source. Putting defaults in a config.* file shipped alongside the application keeps them visible, self-documenting, and overridable by any higher-priority layer.
Objects and arrays are merged deeply; other values replace. When two layers both define a nested object (e.g. database: { host, port }), their keys are merged rather than one object clobbering the other. A layer only needs to specify the keys it cares about. Arrays are merged the same way, by index, so a layer can override a single element (e.g. MY_APP_hosts__1=db2); there is no append semantic. Everything else (strings, numbers, booleans, dates, class instances) replaces unconditionally.
Environment variables always win. Following 12-factor app practice, env vars are applied last and cannot be overridden by any file. This makes containerised deployments and CI pipelines predictable: injecting MY_APP_port=8080 into the environment is enough, regardless of what any config file says.
Local config walks up the directory tree, root-first. The local layer looks for .<appName>.* from the filesystem root down to the current working directory, loading each file it finds. A config file higher up acts as a wider default, one closer to cwd wins — the same intuition as .gitignore or .editorconfig.
Vendor config is immutable at runtime. The vendor layer (the app's own bundled defaults) is never watched for changes. Reloading it on a file-system event during an upgrade or reinstall could leave a running process in an inconsistent state. When using watch(), vendor config is loaded once and folded into defaults before watching begins.
Path resolution is relative to the config file, not the process. String values starting with ./, ../, or ~/ are resolved from the directory containing the config file that defined them. This means a config file can reference sibling files without knowing where the process was launched from — avoiding a common footgun.
Config changes should not require a restart. The watch() API monitors all non-vendor config directories and reloads automatically when any file changes. This lets long-running processes — servers, daemons, background services — pick up updated config without interruption.
Format is the application's choice, not the library's. JSON is supported natively; TOML, YAML, INI, and JSON5 are available by installing the corresponding peer dependency. This lets each application pick the format that suits its operators without imposing a dependency on everyone else. Custom serializers can be passed directly for formats not covered by the built-ins.
Contributions are very welcome, either on the documentation or on the code.
You may:
- report any issue you've encountered;
- fork and create a pull request.
ISC © Julien Fontanet