# Filesystem (loadConfig) (/docs/guides/fs-loadconfig)



## When to use this [#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 [#reference-example]

[`examples/backend-node/`](https://github.com/variableland/env/tree/main/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 [#wiring]

```ts title="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>;
```

```ts title="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;
```

```ts title="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" },
  },
});
```

```ts title="anywhere in your app"
import { env } from "./env/index.ts";

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

## What each piece does [#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](/docs/api-reference/zod).
* **`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) → `config` → `process.env` and validates the result. See [Resolution order](/docs/concepts/resolution).
* **`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](/docs/concepts/env-var-naming).

## Choosing the explicit pattern [#choosing-the-explicit-pattern]

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

```ts
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 [#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` [#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:

```ts
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) [#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&#x60;, or a build-time &#x2A;"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`](https://github.com/variableland/env/tree/main/examples/backend-node-cjs), [`backend-bun-cjs`](https://github.com/variableland/env/tree/main/examples/backend-bun-cjs), and [`backend-deno-cjs`](https://github.com/variableland/env/tree/main/examples/backend-deno-cjs) examples — each boots its server from a CommonJS `require()` entry.

## Tradeoffs [#tradeoffs]

* Requires a runtime with a filesystem — works on Node, Bun, and Deno; not on Workers/Edge. Use the [Vite plugin](/docs/api-reference/vite) 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` / `.cjs` — `require(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.
