Docs
Conceptsince 1.0.0-alpha.3

The schema and your types

One committed file names every key your app needs, and your language types are generated from it.

.env.schema is the only penv file you commit. It says which keys your app needs and what a good value looks like, and it never carries a secret.

What a block looks like

.env.schema
# @penv=acme/api-gateway @schema=1

# The primary Postgres connection.
# @type=url
DATABASE_URL=

# @type=string(startsWith=sk_) @rotate=90d
STRIPE_SECRET_KEY=

# @type=enum(development,staging,production)
NODE_ENV=development

The comment lines directly above a key are its decorators. A comment line that does not start with @ is the key's description. A blank line ends the block.

A decorator is @name or @name=value. The order never matters, and a value holding whitespace or a # goes in quotes.

What penv works out for you

Two things you almost never write by hand.

Required. A key with nothing after the = is required. A key with a value on the line takes that value as its default and becomes optional. Write @required or @optional when you want to say otherwise.

Sensitive. Every key is sensitive unless it carries a bundler prefix that ships it to the browser, or you write @sensitive=false. The prefixes are NEXT_PUBLIC_, VITE_, PUBLIC_, EXPO_PUBLIC_, NUXT_PUBLIC_ and REACT_APP_.

A key that is bundler-prefixed and marked @sensitive fails penv check. The prefix puts the value in your client bundle, so calling it sensitive would be a claim penv cannot keep. Drop the decorator or rename the key.

Sensitivity is what decides whether penv ls masks a value and whether penv run scrubs it out of your program's output.

Types

TypeAcceptsConstraints it takes
stringAny text. The default when @type is absentstartsWith, endsWith, minLength, maxLength
numberA numbermin, max, isInt
booleanA booleannone
urlA URLnone
emailAn email addressnone
portA port numbermin, max
enum(a,b,c)One of the members you listnone

There is no integer type. A whole number is number(isInt=true).

Constraints go inside the parentheses as name=value, so @type=string(startsWith=sk_, minLength=20) is one type with two constraints.

Two more constraints are accepted and penv check enforces neither: matches on a string and precision on a number. penv carries no regex engine, so it keeps them as written and hands them to the cloud and to your generated types, which is where either rule can be applied.

The rest of the vocabulary

penv follows the @env-spec vocabulary word for word, so a file penv wrote reads the same to a varlock user.

DecoratorWhat it does
@example=...A sample value for this key
@docs=...A link to whatever documents this key
@deprecatedMarks a key on its way out
@dynamic / @staticThe spec's pair. penv keeps them and acts on neither

Three decorators are penv's own, for things the spec has no word for.

DecoratorWhat it does
@since=1.4.0The release that introduced this key
@rotate=90dHow often this value should be replaced
@dynamicFrom=aws-sts-assume-roleThe cloud mints this value on request. See short-lived values

At the top of the file, @penv=<org>/<project> names the cloud project and @schema=1 is the grammar version. @defaultSensitive and @defaultRequired up there change the two inferences for the whole file.

A decorator penv does not know is an error from penv check. penv never passes one over in silence, which is what stops a typo like @sensitve from quietly making a key public. There are no aliases either: one concept has one name.

There is no @scope. Who may read a key is a role in the console, and it lives with the environment rather than in a file anyone can edit.

Types your compiler can see

penv gen reads the schema and writes a typed file for your language. It is generated code that ships with your repository, so nothing calls penv at runtime.

penv gen ts     # a typed object over your runtime's env, plus a Standard Schema validator
penv gen py     # penv_env.py on the standard library, or pydantic when your project uses it

penv asks once where the file goes, remembers the answer in .penv/targets/<name>/target.toml, and never asks again. That file is committed, so your teammates are never asked either. --out <PATH> answers it up front for a script.

A key that has a default in the schema reads through the accessor with that default behind it, such as read("PORT") ?? "3000", so its type stays a plain string instead of widening to include undefined. The generated Standard Schema validator returns the typed object.

penv never edits your tsconfig.json or your package.json. It prints the import line for you to paste, using an alias from your existing paths map when one already reaches the file.

Next: local mode and cloud mode.