Secrets
palbase secret manages the credentials your deployed backend reads. Two rules shape the whole surface, and both came from watching the previous one fail: the value never lands — there is no dotenv file, no pull that writes one, no prompt that asks for a value on a terminal, and no listing field that could hold one — and the linked stack is the target, because secrets belong to an Environment and staging's SENTRY_DSN is not production's. A secret exists because it is in that Environment's vault, and for no other reason: nothing in your source tree adds to, restricts or checks that set.
Quick example
# inline — quick, but it lands in your shell history
palbase secret set SENTRY_DSN=https://key@o0.ingest.sentry.io/0
# from stdin — the one that does not
palbase secret set STRIPE_SECRET_KEY --stdin < stripe-key.txt
cat signing-key.pem | palbase secret set SIGNING_KEY --stdin
palbase secret list
▸ todoapp/main
NAME LAST CHANGED
SIGNING_KEY 2026-06-12 10:16
STRIPE_SECRET_KEY 2026-06-12 10:15
Where a secret verb acts
On the environment this checkout resolves to: one environment of the project the committed palbase/project.json names — the selected one, or the one --env <name> names — or the stack running on this machine while the machine-local record palbase start writes exists. Every verb resolves that target, announces it on stderr as ▸ <project>/<environment>, and speaks to /v1/management/secrets there. An unlinked checkout is refused with the four ways to bind it — see Linking a Checkout.
Note: A secret belongs to one environment.
palbase secret set STRIPE_SECRET_KEY --stdin --env stagingwrites staging's vault and leavesmain's alone, so set a secret in every environment that reads it. To act on another project, link a directory to it. See Which environment a command acts on.
secret set
Two forms, one flag.
palbase secret set NAME=value # inline
palbase secret set NAME --stdin # read the value from standard input
| Form | When | Refusals |
|---|---|---|
set NAME=value | short, non-secret-ish values | NAME alone is refused and told both forms; NAME= with nothing after it is refused with <NAME>= has no value — use --stdin to pipe one in, which names only the --stdin form |
set NAME --stdin | keys, PEMs, anything multi-line | NAME=value with --stdin is refused; empty input is refused |
--stdin reads the value whole — trailing newline and all — because a PEM without its final newline is a PEM that fails to parse. It is also the form that keeps a credential out of ~/.zsh_history and out of the process list. It reads at most 64 KiB, which is also the vault's per-value ceiling.
Note: One secret holds at most 64 KiB. A larger value is refused by name rather than stored short, so a key or a PEM that does not fit fails loudly at the moment you set it.
Empty input is refused by name rather than stored:
nothing arrived on standard input — a secret set to empty is a secret nobody notices is gone
On success the command prints the name and never the value — that line is going into a terminal buffer, a CI log and somebody's screen recording:
✓ STRIPE_SECRET_KEY is set
set is create-or-replace: writing a name that already exists overwrites it, with no prompt. The CLI validates no names at all — it splits on the first = (or reads stdin), percent-escapes the name into the path, and PUTs the value. See the reserved namespace below for the one case where that freedom bites.
secret list
Names and when each last changed, sorted by name, times shown in your local zone.
palbase secret list
There is no flag that reveals a value, and the listing has no value field to fill — the wire shape is {name, updated_at}, deliberately, so that a future handler cannot start returning values without somebody deciding to. An Environment holding none says so:
this project holds no secrets
secret remove
palbase secret remove SENTRY_DSN
# ✓ SENTRY_DSN is gone
A hard delete with no tombstone and no confirmation prompt. The running backend notices without being restarted — see rotation below.
palbase run — the values, in one child process
A worker, a script or a local npm run dev often needs the same credentials the deployed code has. That need is what created .env files, and what makes them outlive the reason they were written. So there is no file: palbase run reads the vault and places the values in the environment of one child process.
palbase run -- npm run worker
palbase run -- ./scripts/backfill.sh
palbase run -- npm test --watch
▸ todoapp/main
▸ 3 secret(s): SENTRY_DSN SIGNING_KEY STRIPE_SECRET_KEY
The second banner line is names only, on stderr, so a child whose output is being piped stays parsable and no value reaches the scrollback. Everything else about the command follows from the same rule:
- It reads every name the Environment holds, then one value per name — one request each, because this is the only caller that ever needs values at all.
- Values go into a copy of this process's environment. Nothing is written to disk, nothing is printed, and this shell's own environment is untouched, so a value cannot be inherited by whatever you run next.
- Everything after
--belongs to the child, including its own flags. That is what letsnpm test --watchthrough instead of cobra rejecting--watch. - Ctrl-C belongs to the child. Without that the CLI would take the signal, return, and leave a running process attached to your terminal.
- The child's exit status is the command's exit status, carried out with no message of its own — an error printed above a test runner's output would read as a second, different failure.
A name that cannot be read stops the run at that name rather than starting the child without it:
STRIPE_SECRET_KEY could not be read (…).
Set it again with `palbase secret set STRIPE_SECRET_KEY --stdin`, or remove it if the code no longer wants it
A child that starts without a credential it needs fails later, somewhere else, in a way nobody traces back to here.
How your code reads them
The documented API is the Secrets singleton — one of the ten services you import from @palbase/backend rather than something threaded through a context object:
import { Controller, Get, Secrets, PalError, z } from "@palbase/backend";
const Charge = z.object({ id: z.string(), status: z.string() });
type Charge = z.infer<typeof Charge>;
@Controller("/billing")
export class BillingController {
@Get("/charge")
async charge(): Promise<Charge> {
const key = await Secrets.get("STRIPE_SECRET_KEY");
if (!key) throw new PalError(503, "not_configured", "STRIPE_SECRET_KEY is not set on this environment");
// … use the key …
return { id: "ch_1", status: "succeeded" };
}
}
Secrets.get(name) returns Promise<string | null> and that is the entire interface. It answers null for a name this Environment has not set — including every name the platform holds, because no route returns a platform secret's value at all.
Two other ways in, both real:
process.env.NAME. The runtime mirrors the vault's backend namespace intoprocess.envbefore your bundle is imported, so a dependency that readsprocess.envthe way its own README says works. This exists because a project's controllers readprocess.env.NEO4J_PASSWORD, the runtime put nothing there, and every route that touched it answered 500.meta.envin jobs and webhooks. Handlers receive the Environment's variables on their meta object — see Jobs and Webhooks. A@Webhook's signing secret is declared as an{ env: "NAME" }reference, and the runtime says so at boot when the name has no value, because the alternative is a silent 401 on every authentic delivery.
Seven platform variables are deleted from process.env before your bundle loads — DATABASE_URL, PALBASE_SERVICE_ROLE_KEY, REALTIME_INGESTION_SECRET, INTERNAL_API_SECRET, STACK_ROOT_KEY, PEPPER and LOCAL_JWT_PEM — so your code sees your own values and curated system variables, and nothing else. A vault name of yours that collides with one of those is reported at boot rather than silently shadowing it.
Rotation needs no deploy
A secret written now reaches the running backend on its next tick, with nothing restarted and nothing redeployed. Reads are cached in-process and single-flighted, and the cache is invalidated by a generation counter stamped on the deploy pointer that the runtime already polls — so palbase secret set followed by no further action is a complete rotation. The same is true of a secret written after boot: the runtime re-lists the vault when the generation moves, so a name that did not exist at startup arrives without a redeploy.
The runtime's boot line reports secrets=N. That number is the count of names this Environment's vault holds — secrets=0 means the vault is empty.
What a push does with secrets
Today, nothing. palbase push carries code and schema; it does not read, write or check a single secret, and it cannot refuse a deploy because one is missing.
The machinery for carrying them is still in the binary — fill the names the target does not hold, leave the ones it does alone and report them, replace only with --approve — but it is driven entirely by a declaration file that no current command produces (below). With no declaration it plans nothing, prints nothing and sends nothing. Set the secret on the target with palbase secret set before the code that reads it ships.
Warning: One leftover, and it is a trap rather than a feature. A
.palbase/config.jsonleft on disk by a CLI older than 2026-08-23 is dead — nothing in the CLI reads or writes it any more,palbase pushincluded — but the bytes on disk still describe settings and fixture declarations you may have changed since, from the panel or with the commands on this page. Nothing regenerates that file and nothing consults it either. Delete it.
Nothing reads config/secrets.ts
defineSecrets and secret() are still exported from @palbase/backend, and they still validate their input the moment you author it — a name that is not an env-var shape throws, a name declared twice throws. They are read by nothing. Neither bundler evaluates config/: the CLI-side bundler stages controllers, jobs and hooks and says outright that it no longer looks at the directory, and the cloud-side bundler ends by deleting .palbase/config.json. palbase build produces no evaluated config at all, and a config/ directory containing invalid TypeScript does not fail the build, because nothing compiles it.
So declaring a secret there has no effect on palbase push, on the deploy, or on what your code can read. The two stories the older pages told about it have to be deleted rather than softened:
- There is no pre-flight.
palbase pushnever compares a declared list against the vault and never refuses a deploy naming a missing secret. The refusal string still exists in the binary and is unreachable. secrets=Nis not a declaration count. No declared names ride in the artifact any more; the runtime reads them straight from the vault namespace, so the boot line counts what the vault holds.
A config/secrets.ts left over from an older checkout is ignored completely: nothing reads it at build time, nothing reads it at deploy time, and the runtime never sees it. Delete it. Settings, secrets and fixture templates have one door, and it is the management API: config/*.ts files are evaluated by no build, travel in no artifact, and are ignored if present. See Stack Settings.
The reserved PB_NOTIFICATIONS_* namespace
Names under PB_NOTIFICATIONS_ are managed provider secrets: palbase notifications add apns uploads the APNs key to PB_NOTIFICATIONS_APNS_P8 and creates the provider configuration in one step, so setting one by hand leaves the configuration half-made. Use the guided command — the provider table is on Stack Settings.
That namespace is reserved by convention only. palbase secret set checks for no prefix, so palbase secret set PB_NOTIFICATIONS_SENDGRID_API_KEY=… succeeds and overwrites what notifications add wrote. Nothing stops you; leave the namespace alone. (An SDK doc-comment claims the CLI refuses these names. It never has.)
secret remove is still the way to purge one after removing its provider.
Related
- Secrets — the vault, the Environment boundary, and how values reach a running backend
- Secrets —
Secrets.getfrom backend code, and what it answers for a name that is not set - Stack Settings — the other settings commands, and
palbase notifications add - Linking a Checkout — which Environment a secret verb acts on
- Outbound Network — declaring the hosts a credential is used against
- Deploying — what
palbase pushactually ships