Guides

Filesystem (loadConfig)

Wire @vlandoss/env in a long-running server. Schema, per-environment config files on disk, and a typed env loaded at boot via loadConfig.

When to use this

  • You're running a long-lived process with filesystem access (Node, Bun, Deno — HTTP server, worker, CLI).
  • Per-environment config lives as .ts / .json files in the repo.
  • Secrets come from real process.env at boot.

Reference example

examples/backend-node/ — Node HTTP server (Hono via @hono/node-server) using Zod, the @vlandoss/env/zod primitives, and the short form of loadConfig.

Wiring

src/env/schema.ts
import { type Config, schema } from "@vlandoss/env";
import * as e from "@vlandoss/env/zod";
import * as z from "zod";

export const Env = schema({
  log: { LEVEL: e.logLevel },
  server: { PORT: e.port, HOST: e.host },
  db: {
    URL: z.url(),
    LOGGING: e.bool.default(false),
  },
});

export type EnvConfig = Config<typeof Env>;
src/config/development.ts
import type { EnvConfig } from "../env/schema.ts";

export default {
  log: { LEVEL: "debug" },
  server: { PORT: 3001, HOST: "127.0.0.1" },
  db: { URL: "postgres://localhost/dev", LOGGING: true },
} satisfies EnvConfig;
src/env/index.ts
import { defineEnv } from "@vlandoss/env";
import { loadConfig } from "@vlandoss/env/fs";
import { Env } from "./schema.ts";

const config = loadConfig(Env);

export const env = defineEnv({
  schema: Env,
  config,
  vars: {
    db: { URL: "DATABASE_URL" },
  },
});
anywhere in your app
import { env } from "./env/index.ts";

server.listen(env.server.PORT, env.server.HOST);

What each piece does

  • schema() is the contract. Leaves use Standard Schema validators (Zod here, but any Standard Schema lib works). The @vlandoss/env/zod primitives (e.port, e.host, e.logLevel, e.bool) are opinionated single-purpose schemas — see the Zod primitives reference.
  • config/<envName>.ts is the typed, versioned default per environment. satisfies EnvConfig is what gives you compile-time errors on typos. Secrets do not go here — leave them out of config/* and put them in process.env.
  • loadConfig(Env) is the short form: it synchronously auto-discovers [src/]config/<envName>.{ts,mts,cts,js,mjs,cjs,json} under process.cwd() and returns the first match (or {} if none) — no await.
  • defineEnv merges defaults (none here) → configprocess.env and validates the result. See Resolution order.
  • vars: { db: { URL: "DATABASE_URL" } } overrides the convention for one leaf. db.URL would default to DB_URL; here we map it to the conventional DATABASE_URL. See Env-var naming.

Choosing the explicit pattern

If your config directory doesn't follow the convention, use the long form:

const config = loadConfig({ schema: Env, pattern: "config/{env}.ts" });

The pattern must contain {env}, and it throws if the resolved file doesn't exist (the short form silently falls back to {}).

Loading a different env

loadConfig always reads envName(). To load a non-current env, set ENV=… in the process env before calling — there's no env override on the function itself.

Resolving from a custom cwd

Both auto-discovery and the {env} template resolve paths against process.cwd() by default. If the process working directory isn't the project root (monorepo task runners, orchestrators, SSR workers launched from elsewhere), pass cwd explicitly:

const config = loadConfig({ schema: Env, cwd: appRoot });                       // auto-discovery
const config = loadConfig({ schema: Env, pattern: "config/{env}.ts", cwd: appRoot }); // template

Config files loaded synchronously (require() / CJS)

Because loadConfig is synchronous, it also works in config files that tooling loads synchronously — files pulled in via require() or bundled to CJS, where a top-level await would be rejected (ERR_REQUIRE_ASYNC_MODULE, or a build-time "top-level await is not supported with the cjs output format"). The wiring is exactly the same as above; there's no await to trip over.

See the runnable backend-node-cjs, backend-bun-cjs, and backend-deno-cjs examples — each boots its server from a CommonJS require() entry.

Tradeoffs

  • Requires a runtime with a filesystem — works on Node, Bun, and Deno; not on Workers/Edge. Use the Vite plugin for those instead.
  • loadConfig loads files with require(). Runtime requirements by extension:
    • .ts / .mts / .cts — native TypeScript stripping (native in Bun/Deno, Node ≥22.18).
    • .mjs / .js / .cjsrequire(esm) (native in Bun/Deno, Node ≥22.12).
    • .json — works on any supported Node.
  • Module loads are cached by Node/Bun/Deno's module system. Editing a .ts/.mjs/.cjs config in a long-running process isn't picked up until the process restarts; .json files are re-read on every call.

On this page