Docs

You already know the CLI.

Works with the open-source penv command line tool, which stays exactly as it is. New to penv? The docs start there.

01

The whole adoption story is one line.

Switch an environment's provider in penv.config.ts, run penv pull, and your app reads secrets the same way it did against local disk, now audited and encrypted with envelope + AWS KMS.

Development keeps its filesystem provider. Nothing about how your code reads a value changes.

penv.config.ts
import { defineConfig } from "@penvhq/penv"; export default defineConfig({  environments: ["development", "production"],  providers: {    development: { type: "@penvhq/provider-filesystem" },    // one line moves production's secrets to the hosted, audited store    production: { type: "@penvhq/provider-penv-cloud" },  },});
02

Local development

Addresses are hierarchical and the hierarchy is enforced rather than conventional. A parameter lives at organization / project / environment / path / name, and that address is the data the ciphertext is sealed against, so a value cannot be read from a scope it was not written to. Staging cannot quietly resolve a production value.

penv pull writes what your app expects and nothing it should not have. Dynamic parameters are the case worth knowing: pull never mints one. It materializes a marker, and penv doctor reports the parameter as runtime-resolved, so an expired lease can never be baked into a local file and shipped.

03

CI without a stored key

A runner already proves who it is. Give the job an OIDC token and trade it for a credential, and there is no long-lived secret in your CI settings to leak or rotate.

There are two exchanges, and which you want depends on what the job needs:

POST /auth/oidc
1 hour · a machine credential
POST /auth/oidc/delivery
5 minutes · carries values into the build
POST /auth/revoke
end of job · best effort

The delivery route is the stricter of the two. It records the run on the credential and it refuses pull_request_target, because that trigger runs with the base repository's permissions against code from the pull request head. Move the read into a job the fork cannot influence.

Treat the expiry as the only guarantee.

The end-of-job revoke shortens the window when it runs, and a force-cancel or a killed runner skips it. Five minutes is short enough to be the answer on its own, which is why the delivery credential is scoped to the read at the start of the step rather than to the length of the build.
04

Dynamic secrets and the 409

A dynamic parameter has no stored value. It mints one from the upstream provider when a machine asks, the credential is in the response once, and no table holds it. Leasing is opt-in per environment and fails closed.

Because the address exists but holds nothing, the secrets API answers 409 with a dynamic reason on all three verbs rather than guessing what you meant:

GET
409 · a 404 would invite you to overwrite it
PUT
409 · a stored version would open for nothing
DELETE
409 · would cascade away live leases

Handle it as its own signal rather than folding it into "not found". A client that treats 409 as absence is one deploy away from writing a static value over a live engine.

GitHub Actions

.github/workflows/deploy.yml
permissions:  id-token: write   # let the runner mint an OIDC token  contents: read steps:  - name: Read production secrets    run: |      TOKEN=$(curl -sS -X POST "$PENV_CLOUD_URL/api/v1/auth/oidc/delivery" \        -d "{\"token\":\"$ACTIONS_ID_TOKEN\"}" | jq -r .credential)      PENV_TOKEN="$TOKEN" penv pull --env production

Get your keys out of the chat.

Three people free. Machines never take a seat.