Skip to main content

env

What a deployment needs in its environment, from what its apps declare: a build that refuses to deploy without it and says exactly what to add, a setup that adds it through the Vercel CLI, and a rotation of the secrets it generated, with no downtime.

pnpm dist env check [--build] [--environment production|preview]
pnpm dist env setup [--environment production|preview] [--dry-run]
pnpm dist env rotate [variable...] [--yes] [--no-redeploy] [--dry-run]
pnpm dist env workflow

Which deployments

A distribution with apps/monolith deploys it: one Vercel project, serving every app, which has every app's variables — and the monolith's own, if it declares any. Without one, each app is a project of its own, and each project has its own. A value two apps have to share — the secret one signs in to the other with — is set alike in both, when one says so with sameAs. Two projects that name a secret alike, untied, each have their own; a name sameAs ties has to be declared for one project only. A variable set for several environments at once — the dashboard's default — is one record, which Vercel changes or removes for all of them, so rotating one environment refuses it: keep it for one, and let setup give each of the others its own.

Two apps in one deployment may declare the same variable, alike in how it is set. It is needed while any of them needs it: its unless excuses it only when every app declaring it says so.

Every app is read from its checkout, so each submodule has to be checked out (git submodule update --init); one that is not is an error, not an app that needs nothing.

Which project deploys what is kept in the distribution's package.json, so a scheduled rotation in CI finds them without a vercel link. setup asks the first time, and writes it down:

"distribution": {
  "vercel": { "scope": "acme", "projects": { "apps/monolith": "acme-core" } }
}

In the build

pnpm dist env check --build

fg-dist init runs it first, before anything is built, so a deployment missing something fails in seconds rather than at its first request:

@acme/core-monolith is missing what a production deployment needs:

  FG_ID_SMTP_URL  (@fairgarden/id)
    The SMTP server sign-in codes are emailed through; without it, nobody can sign in by email.
    For example smtps://user:password@smtp.example.com:465
  FG_MEMBERS_SECRET  (@fairgarden/members)
    Encrypts the session cookie.
    A long random value: `pnpm dist env setup` makes one.

Add them to the Vercel project for Production, or run
`pnpm dist env setup` where the distribution is checked out, which adds them for you.

It checks on Vercel, for the environment being built — production or preview — and nowhere else, unless FG_ENV_CHECK=build; FG_ENV_CHECK=skip turns it off. It reads the environment files Next reads for a build, as migrate does.

Without --build, it checks each deployment's Vercel project instead, through the CLI.

Setting it up

pnpm dist env setup

Adds what each project is missing, and nothing it has:

  • A secret an app generates is made here, 32 random bytes, and set alike in every project that shares it. Every secret is sensitive: once set, only a deployment can read it — not setup, not rotation, not anyone on the team.
  • Where one side of a shared secret is set already, its value cannot be read to match. Both sides move to a new value in their next slot instead, and the side that had one goes on accepting it.
  • A value the app fixes is set as it says.
  • Anything else is asked for, when there is someone to ask; otherwise it is listed, and setup fails.
  • A database is not made here: connect Neon to the project, which sets DATABASE_URL.

--environment preview does the same for previews. Values go to the CLI on standard input, never as arguments any other process could read.

Rotating secrets

pnpm dist env rotate

A secret is never read back, so rotating cannot keep the old value by reading it. A rotated secret has two slots instead, NAME_A and NAME_B, both sensitive, and NAME_CURRENT — a letter, and no secret — says which is in use. The app signs and sends with that one and accepts both. Rotating writes a new value into the other slot, over the one from two rotations ago, then points at it. So a session sealed, or a sign-in begun, just before a rotation still works after it — until the next one.

A secret two apps share is rotated where it is checked first, and redeployed, before what sends it: then there is no moment one side has a value the other does not. In a monolith both apps read the one variable, so one redeploy does. Production is redeployed after each step — the deployment serving it now, so an instant rollback is kept, not undone.

Which slot is in use is read from the pointer; where a team policy makes every variable sensitive (Team Settings → Security & Privacy), even that, it is the slot set most recently, which Vercel says of any variable. A secret set by hand as plain NAME is still accepted beside the slots the first rotation starts, and the next one removes it. Name variables to rotate only those, and the ones tied to them; a name that is not a rotated secret is an error, not a rotation of nothing.

A slot is chosen from each project's settings, which production runs only once it is redeployed. So rotating first redeploys any project whose production was made before one of its secrets last changed — a rotation that stopped before redeploying it, or an instant rollback to before one — where the secret is checked before where it is sent. Then the new value goes into the slot nothing live sends from. With --no-redeploy it refuses instead, and says which to redeploy first: deploy between rotations you run that way.

A session sealed with a value lasts until the second rotation after it, so rotate less often than a session lasts, or people are signed out. Members' sessions last 12 hours; monthly is well clear of it.

pnpm dist env workflow

Writes .github/workflows/rotate-secrets.yml, which rotates on the 1st of every month and on demand. It signs in to Vercel with VERCEL_TOKEN, an Actions secret of the repository.

What an app declares

Under fairgarden.env in its package.json:

"FG_MEMBERS_SECRET": {
  "description": "Encrypts the session cookie.",
  "required": "deployed",
  "generate": "secret",
  "rotate": true
}
descriptionwhat it is for, shown to whoever has to set it
requireddeployed — any deployment — or production alone; otherwise optional
sensitivea secret nobody should read back; every generated one is too
generatesecret: setup makes it, and rotate can replace it
rotatekept in two slots and a pointer, which the app reads with @fairgarden/distribution/secrets
valuewhat it is set to; nothing to ask
exampleshown when it is asked for
unlessnot needed when any of these variables is set
unlessMountednot needed when the app shares a monolith with this package: it works it out
deploymentthe package whose deployment reads it, when that is not the app's own
sameAsthe same value as this other rotated secret, which some app declares
verifiesit checks a shared secret, so it is rotated first; every tied group has one

deployment is how an app says what another needs to know about it: members declares the variables the id service enrols it with, for the id service's project — unless they share a monolith, where the id service works them out.

An app with migrations needs a database on any deployment, without declaring it.