Recipes
Idiomatic compositions of @mongez/dotenv features.
Boot at process start
// src/bootstrap.ts — imported first by your entry pointimport { loadEnv } from "@mongez/dotenv";
loadEnv();import "./bootstrap"; // make sure this runs before anything elseimport { env } from "@mongez/dotenv";
const app = createApp();app.listen(env("APP_PORT", 3000));Per-environment config with shared defaults
APP_NAME="My App"APP_DESCRIPTION="A web app"
# config/.env.developmentDB_URL="mongodb://localhost/dev"DEBUG=trueLOG_LEVEL=debug
# config/.env.productionDB_URL="mongodb+srv://prod-host/app?retryWrites=true&w=majority"DEBUG=falseLOG_LEVEL=infoimport path from "node:path";import { loadEnv, env } from "@mongez/dotenv";
loadEnv(undefined, { dir: path.resolve(__dirname, "../config"),});
env("APP_NAME"); // "My App" (from .env.shared, both envs)env("DEBUG"); // true | false (per-environment)env("DB_URL"); // mongo URL (per-environment)Reading typed values straight into a config object
import { env } from "@mongez/dotenv";
export const config = { app: { name: env("APP_NAME", "App"), port: env("APP_PORT", 3000) as number, debug: env("DEBUG", false) as boolean, }, db: { url: env("DB_URL") as string, },};The as casts are because env(...) returns any. Layer zod / valibot on top if you want runtime validation:
import { z } from "zod";import { env } from "@mongez/dotenv";
const schema = z.object({ APP_PORT: z.number().int().positive(), DEBUG: z.boolean(), DB_URL: z.string().url(),});
export const config = schema.parse({ APP_PORT: env("APP_PORT"), DEBUG: env("DEBUG"), DB_URL: env("DB_URL"),});Loading multiple files in a specific order
import { loadEnvFile } from "@mongez/dotenv";
loadEnvFile("/etc/myapp/base.env", true); // global baseloadEnvFile("/etc/myapp/local.env", true); // host-specific overridesloadEnvFile("./.env", true); // per-checkout overrides lastEach subsequent file with override: true overwrites keys from the previous one.
Read-only mode (don’t touch process.env)
import { loadEnv, env } from "@mongez/dotenv";
loadEnv(undefined, { override: false });
process.env.APP_PORT; // unchangedenv("APP_PORT"); // typed value from the fileoverride controls only the write-back. It does not control precedence: with the default precedence: "file-wins", the file value still replaces the injected one inside the store, so env("APP_PORT") returns the file’s value even when the orchestrator set a different one. Use precedence for that.
Let the platform’s injected values win (Docker / Kubernetes / CI)
import { loadEnv, env } from "@mongez/dotenv";
loadEnv(undefined, { precedence: "process-wins" });
// DATABASE_URL injected by the platform → the .env line is ignored,// and process.env is left intact.// APP_NAME only in .env → the file value is used.env("DATABASE_URL");env("APP_NAME");Reach for this in any containerised deploy. The default, "file-wins", means a .env file baked into the image silently overwrites what the platform injected — the exact inversion of what dotenv, dotenv-flow, Vite and Next do. It stays the default for all of v1.x so a minor bump never changes what a running deployment reads; v2.0 flips it.
Combine with override: false when process.env should also be left untouched for the keys the file does own:
loadEnv(undefined, { precedence: "process-wins", override: false });Reading a variable that only the platform sets
import { env } from "@mongez/dotenv";
// No .env file declares NODE_ENV or PORT — the platform injected them.env("NODE_ENV", "development"); // "production" (from process.env)env("PORT", 3000); // 8080 (coerced to a number)env() reads store → process.env → default, so this works even before loadEnv() has been called. Values taken from process.env get the same narrow coercion a file value gets, without quote stripping or ${VAR} interpolation.
Optional .env (no file on disk)
import { loadEnv } from "@mongez/dotenv";
loadEnv(); // directory has no .env, .env.shared, or .env.<NODE_ENV> → no-oploadEnv() skips the paths it derives when nothing is on disk, so a container that gets everything from injected variables needs no guard. A path you pass explicitly still throws when missing:
loadEnv("/etc/myapp/secrets.env"); // throws if absent — you named itDistinguishing a loaded null from a missing key
env(key) preserves a deliberately-loaded null (it checks key in envData rather than ??), so the loaded value passes through directly:
import { env } from "@mongez/dotenv";
// .env contains: EST_TIME=nullenv("EST_TIME"); // nullenv("EST_TIME", "fallback"); // null (loaded null wins over default)env("MISSING"); // undefinedenv("MISSING", "fallback"); // "fallback"Reset in tests
resetEnv clears the internal store, deletes any process.env keys that the loader wrote since module load, and re-applies the import-time snapshot — so a single call is enough to undo a loadEnv between tests:
import { afterEach } from "vitest";import { resetEnv } from "@mongez/dotenv";
afterEach(() => { resetEnv();});Keys that the test sets directly on process.env (without going through loadEnv / loadEnvFile) are not tracked by the loader and survive the reset — manage those yourself if needed.
Quoted values with #
The parser is quote-aware: when a value opens with ", ', or `, the matching closing quote is the last occurrence of that same character, and anything after it (whitespace + a # comment) is discarded.
# Fully-quoted, contains #, no trailing commentDB_PASS="AMFSDF#QWEWQE"# → "AMFSDF#QWEWQE"
# Fully-quoted, contains #, with a trailing commentHTTP_URL2="https://${HOST}#fragment" # some comment# → "https://example.com#fragment"
# Unquoted, no # — trailing comment is fineAPP_NAME=My App