Docs
Guidesince 1.0.0-alpha.3

Adopt an existing app

Turn the .env file you already have into one committed schema, then move the values to the cloud and delete the file.

You have a .env file and a repository. By the end of this page the file is gone from your working tree, one small file is committed in its place, and a teammate who clones the repository types one command. Penv Cloud adopted itself this way, and its own repository is the example below.

What init reads and what it writes

penv init reads the .env beside you, writes .env.schema, and adds .env to your .gitignore. With no .env there it writes an empty one and tells you to add keys and run penv init --force.

Every key comes out sensitive and required unless a bundler prefix such as NEXT_PUBLIC_ or VITE_ says otherwise. A present value on the line becomes the default and makes the key optional.

A value is copied into the schema only when it is dull: a boolean, an integer, a lowercase word, a short lowercase slug such as us-east-1 or gpt-4o, or a localhost URL. A key named for what it holds keeps its value out however dull that value reads. Those names are AUTH, KEY, SECRET, TOKEN, PASSWORD, PASSWD, PASSPHRASE, PASS, PWD, PW, CREDENTIAL, CRED, DSN, SALT, SEED and SIGNATURE, each matching the word itself or a plural in S or ES. So NEXT_PUBLIC_SUPABASE_ANON_KEY is public and its value still stays out.

At a terminal init also offers a picker over every coding agent harness penv knows, with the ones it found installed already chosen, and it generates the typed file for each language your repository uses.

init prints what it inferred:

KEY                TYPE     REQUIRED  SENSITIVE
DATABASE_URL       url      yes       yes
STRIPE_SECRET_KEY  string   yes       yes
PORT               port     no        no

Read the schema and fix the guesses

.env.schema is the one penv file you commit. Decorators sit in # comment lines directly above a key, and a comment line that does not start with @ is that key's description.

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

# @type=url
DATABASE_URL=

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

# @type=url @sensitive=false
NEXT_PUBLIC_APP_URL=http://localhost:3000

# @type=port
PORT=3000

The vocabulary follows the @env-spec grammar, so @type=, @required, @optional, @sensitive, @example and @docs all mean what a varlock user expects. Order never matters and there are no aliases. An unknown decorator is an error. The full grammar is on the schema format page.

Run penv check after every edit:

penv check

It reports schema problems and keys with no value, and it adds a line for guard coverage. A violation sets exit code 3 and names the key. Two things it reports without failing: a key that has a value in .env and no block in the schema, which it calls drift, and a guard file that is stale or missing.

A key that is both bundler-prefixed and marked @sensitive fails check. Pick one. A prefixed value reaches the browser whatever the schema says about it.

The worked example

Penv Cloud commits one .env.schema at the repository root and one remembered target file. Its schema declares the keys its server needs, among them DATABASE_URL, STRIPE_SECRET_KEY, RESEND_API_KEY, GITHUB_APP_PRIVATE_KEY and SAML_SP_PRIVATE_KEY_PEM. Every one of those blocks carries decorators and no value.

The generated file at apps/web/src/env.ts opens with the line penv writes, so nobody edits it by hand:

apps/web/src/env.ts
// Generated by penv 1.0.0-alpha.3 from .env.schema. Do not edit.

const read = (key: string): string | undefined => process.env[key];

Generate typed access

penv gen ts

penv asks once where the file goes, then remembers the answer in .penv/targets/<name>/target.toml, which you commit. Penv Cloud's looks like this:

.penv/targets/ts/target.toml
name = "ts"
output = "apps/web/src/env.ts"

[options]
# penv: Property names in the exported object: the environment key, or its camel form. (upper|camel)
key_case = "camel"
# penv: The accessor every key is read through: process.env, import.meta.env or Deno.env.get. (node|vite|deno)
runtime = "node"

penv never edits your tsconfig.json or your package.json. It prints the import line for you to paste. Typed access for your language covers the options and how to add a language of your own.

Write the harness rules

penv guard

This writes what each coding agent harness on this machine enforces, from your schema, into that harness's own config. It merges into a file you already have and never weakens a rule that is there. penv guard --check reports coverage without writing. Keep agents out of .env has the detail per harness.

Move the values to the cloud

penv login
penv push

login prints an eight-character code and the page to open, and the credential lands in your operating system keychain. push reads the values out of .env, sends every key with its decorators, and deletes the file.

When .env.schema carries no @penv= header, push says which project it is about to create, creates it with a development environment, and writes the slug the server returned into the header. If your account is in more than one workspace it refuses and lists them, so pass penv push --org <slug>.

A key with no value still travels: it updates the stored schema for that key and writes no version. A value with no block in the schema travels too, with no schema invented for it.

push deletes .env once the write succeeds. That is the point, and it is reversible: penv pull writes the file again from the cloud whenever you need it.

Do it in order

  1. penv init, and pick the harnesses when it asks.
  2. Open .env.schema and fix any type or sensitivity it guessed wrong.
  3. penv check until it prints ok.
  4. penv gen ts (or penv gen py) and answer where the file goes.
  5. penv run -- <your dev command> to prove the app still starts.
  6. penv login.
  7. penv push, then commit .env.schema and .penv/targets/.

Staging and production