---
title: Configuration
description: Config file format, dotenv parsing, validation semantics, and source composition for Envlock.
url: https://pr-1-b9e16090e83d.thally.app/envlock/configuration
---

# Configuration

Config file format, dotenv parsing, validation semantics, and source composition for Envlock.

# Configuration

This page covers how Envlock discovers and loads your contract file, the dotenv
format it parses, how validation behaves under the hood, how to compose
multiple sources, and how secrets are protected throughout the system.

## Config File Discovery

The CLI and MCP server look for a config file in the working directory by
probing two filenames in order:

1. `envlock.config.mjs`
2. `envlock.config.js`

The first file found is used. You can skip discovery and point to a specific
file instead:

- **CLI** -- pass `--schema <path>` to any subcommand.
- **MCP** -- pass the `schemaPath` argument to any tool.

The config file must export a schema created with `defineEnv`. Two export
shapes are accepted:

```js
// default export
export default defineEnv({ /* ... */ });

// named export
export const schema = defineEnv({ /* ... */ });
```

Config files are loaded via dynamic `import()`, so they must be files that
Node.js can execute directly -- either `.mjs` (ESM) or `.js` in a project
with `"type": "module"` in its `package.json`.

## Starter Config

Running `envlock init` creates an `envlock.config.mjs` file in the current
directory with a commented-out contract covering common variables. The command
refuses to overwrite an existing `envlock.config.mjs` or `envlock.config.js`
(exits with code 1).

Here is a full example config (the basic-app example from the repository):

```js
import { defineEnv, env } from "@envlock/core";

export default defineEnv({
  NODE_ENV: env
    .enum(["development", "production"])
    .default("development")
    .describe("Runtime mode"),
  PORT: env.port().default(3000).describe("HTTP listen port"),
  DATABASE_URL: env
    .url({ protocols: ["postgres:", "postgresql:"] })
    .secret()
    .describe("Primary Postgres connection string")
    .example("postgres://user:pass@localhost:5432/app"),
  SESSION_SECRET: env
    .string()
    .secret()
    .describe("Key used to sign session cookies"),
  REQUEST_TIMEOUT: env
    .duration()
    .default(30_000)
    .describe("Upstream request timeout"),
  ALLOWED_ORIGINS: env
    .list()
    .default(["http://localhost:3000"])
    .describe("CORS allow-list"),
  FEATURE_FLAGS: env
    .json()
    .optional()
    .describe("Optional JSON object of feature toggles"),
});
```

## Variable Naming

Variable names passed to `defineEnv` must match the pattern
`/^[A-Za-z_][A-Za-z0-9_]*$/` -- letters, digits, and underscores, not starting
with a digit. If any name violates this rule, `defineEnv` throws a `TypeError`:

```
Invalid environment variable name "2FAST": use letters, digits and underscores, not starting with a digit
```

## Dotenv Format

The `parseDotenv` function is a dependency-free parser for `.env` files.
It supports the following syntax:

- **Simple pairs** -- `KEY=value`
- **Export prefix** -- `export KEY=value` (the `export` keyword is stripped)
- **Comments** -- lines starting with `#` are ignored
- **Single-quoted values** -- taken literally with no escape processing
- **Double-quoted values** -- supports `\n`, `\r`, `\t`, `\"`, `\\`, and `\$` escapes
- **Multi-line quoted values** -- both single and double quotes can span lines
- **Empty values** -- `KEY=` sets the key to an empty string
- **Inline comments** -- a `#` preceded by whitespace ends an unquoted value

Additional behaviours:

- CRLF line endings are normalised to LF.
- Malformed lines are silently skipped.
- When a key appears more than once, the last value wins.
- The function never throws.

### Serialising with `formatDotenv`

`formatDotenv(record)` converts a `Record<string, string>` back to
dotenv-formatted text. Keys are written in sorted order, and values are quoted
only when necessary. The round-trip `parseDotenv(formatDotenv(r))` produces
a record equal to `r`. An empty record returns an empty string.

```ts
import { parseDotenv, formatDotenv } from "@envlock/core";

const parsed = parseDotenv('PORT=3000\nDEBUG=true');
const text = formatDotenv(parsed);
// DEBUG=true
// PORT=3000
```

## Validation Semantics

All validation in Envlock flows through a single code path -- `parseEnv`. The
`loadEnv` function, `diffEnv`, the CLI, and the MCP server all delegate to it.
This means the rules below apply uniformly everywhere.

### Declaration order

Fields are validated in the order they are declared in the `defineEnv` call.
Issues for declared keys appear in declaration order, followed by any unknown
keys sorted alphabetically.

### Empty string is absent

Both `undefined` and `""` are treated as the variable being unset. If you write
`KEY=` in a dotenv file, Envlock considers that variable absent. A required
field with no default will produce a `missing` issue; a field with `.default()`
will resolve to the default; a field with `.optional()` will be omitted from the
result.

### Strict mode

When `strict: true` is passed, source keys that are not declared in the schema
produce `unknown` issues. This is only meaningful for bounded sources such as
dotenv files. Passing `strict: true` against `process.env` would flag every
system variable as unknown, so the CLI prints a note to stderr and ignores
the flag when no `--env-file` is provided.

## Source Composition

Envlock never mutates `process.env`. When you need to layer a dotenv file on
top of the process environment, compose the sources yourself:

```js
import { parseDotenv, loadEnv } from "@envlock/core";

const dotenvSource = parseDotenv(dotenvText);
const config = loadEnv(schema, { ...dotenvSource, ...process.env });
```

Because `process.env` is spread last, real environment variables take precedence
over file values. Reverse the order if you want the file to win.

### The `--merge-process-env` CLI flag

The `envlock check` command supports `--merge-process-env` alongside
`--env-file`. When both flags are set, the CLI layers the dotenv file over
`process.env` with file values taking precedence, then validates the merged
source against the contract:

```sh
envlock check --env-file .env --merge-process-env
```

Without `--merge-process-env`, `--env-file` validates only the file contents
in isolation.

## Secret Handling

Fields marked with `.secret()` are protected at every layer of the system to
prevent accidental exposure of sensitive values.

### Issue masking

When a secret field produces an `invalid` issue, the `received` property on the
`EnvIssue` is set to `"••••••"` instead of the actual value.

### Schema description

`describeSchema` masks secret defaults with the `REDACTED_VALUE` constant
(`"••••••"`) and omits the `exampleValue` property entirely for secret fields.

### Example rendering

`renderExample` always renders secret variables as `KEY=` with no value,
regardless of whether `.example()` or `.default()` was set. If a secret has a
default value, the metadata comment shows `(hidden)` rather than the real
default.

### Redaction

`redact(values, schema)` returns a shallow copy of the values object with every
`.secret()` field replaced by `"••••••"`. The input object is never mutated.

```ts
import { loadEnv, redact } from "@envlock/core";
import schema from "./envlock.config.mjs";

const config = loadEnv(schema);
console.log("Safe to log:", redact(config, schema));
// DATABASE_URL and SESSION_SECRET will appear as "••••••"
```