Composed secrets
Build one secret out of others with ${KEY} references, so rotating a password updates every connection string that embeds it.
A composed secret is a pointer, not a copy. Instead of storing a connection string that happens to contain your database password, you store its shape and name the password:
DATABASE_URL = postgres://${DB_USER}:${DB_PASS}@db.internal/app
Rotate DB_PASS once and every secret built from it changes with it. There is nothing else to find and nothing else to update.
Why it matters
Without composition, a password that appears in four connection strings exists in five places. Rotating it means editing all five, and missing one causes a partial outage that looks like a credential bug: the service is up, the password is right, and one worker is still using the old one.
With composition the password exists once. Everything that embeds it follows it, every read of it is audited, and varsafe refuses the changes that would quietly break the link.
Composition is something you choose per secret. varsafe does not expand ${...} in ordinary values, and never has (see What composition is not).
Create one
In the dashboard, switch the value field from Value to Composed, then type ${ to pick a key from the environment. The editor suggests keys as you type and warns you if a reference would create a loop. If you type ${ into an ordinary value, the dashboard offers Compose it from other secrets instead; it never turns a value into a composition on its own.
From the CLI, pass the template with --template. Every key it references must already exist in the environment, so store those first. Here that means DB_USER and DB_PASS, set with varsafe set DB_USER and varsafe set DB_PASS:
varsafe set DATABASE_URL --template 'postgres://${DB_USER}:${DB_PASS}@db.internal/app'In the Secrets table a composed secret carries a composed badge, with the keys it is built from and the keys that use it listed beneath.
The syntax
${KEY} and nothing else. Every omission is deliberate:
| Not supported | Why |
|---|---|
$KEY (no braces) |
It has no unambiguous end: $FOO_BAR cannot be told apart from ${FOO}_BAR, so renaming a key could silently change which secret a value points at. |
${VAR:-default} |
An empty value is a legal secret, so a default cannot tell “unset” from “deliberately empty”. It would hide a missing production secret behind a fallback that looks like it worked. |
| Falling back to the machine’s environment | The result would depend on where the read happens, so CI and a laptop would disagree about what a secret is. |
| References across environments or projects | A composition resolves inside one environment, so reading it is authorized exactly like reading an ordinary secret. |
To include a literal ${ in a composed value, escape it as \${. A backslash anywhere else stays a backslash, so Windows paths such as C:\$Recycle.Bin need no special handling.
References follow the same rule as secret keys: uppercase letters, digits and underscores, starting with a letter. A template can only name something that could exist.
Limits
| Limit | Value |
|---|---|
| Distinct keys one template references | 32 |
${...} placeholders in one template |
64 |
Depth of a chain (A built from B built from C …) |
8 |
| Secrets one read may resolve through, in total | 64 |
| Size of one resolved value | 64 KB |
| Size of a whole resolved environment | 1 MiB |
Loops are refused when you save, including the one-step A = ${A}, and so is a template that names a key the environment does not have.
Size limits apply to the resolved value, which is only computed when the secret is read. A short template can therefore save successfully and still exceed 64 KB once its references are substituted in. The read is what refuses it, not the write.
Read one
Everything that delivers values to your code delivers the resolved result, in varsafe run, varsafe export and varsafe get alike:
varsafe run -- ./serverYour process receives the resolved value, never the template:
DATABASE_URL=postgres://alice:hunter2@db.internal/app
To see what was written rather than what it resolves to, ask for the source:
varsafe get DATABASE_URL --source # postgres://${DB_USER}:${DB_PASS}@db.internal/app
varsafe list --reveal --source
varsafe list --reveal marks each secret’s kind as composed or literal.
Resolution is all-or-nothing
If any part of a composed value cannot be resolved (a missing reference, a loop, a limit exceeded), the read fails. It never returns a partial result and never hands your process a literal ${...}.
That is deliberate. A half-resolved connection string is worse than an error: the process starts, connects to the wrong place or fails to authenticate, and the cause is several layers away from the setting that produced it.
What each change does
| You | What happens |
|---|---|
Set it again with --template, or edit it in the dashboard |
It is built from the new template |
Overwrite it with a plain value, from varsafe set, a rotation, an import or an AI agent |
Refused |
| Choose Convert to plain value in the dashboard’s Edit dialog | It becomes a plain value |
| Copy it to another environment | The destination gets the resolved value, as a plain secret |
| Duplicate the whole environment | It stays composed |
| Rotate a secret it references | It resolves to the new value |
| Rotate the composed secret itself | Refused |
| Delete a secret it references | Refused until you choose --flatten or --cascade |
The sections below explain each one.
Changing one
To change what a composed secret is built from, set it again with --template, or edit it in the dashboard.
varsafe refuses to overwrite a composed secret with a plain value, whether the write comes from varsafe set, a rotation, an import or an AI agent. Such an overwrite would succeed, look correct, and silently freeze the secret at its current value, so the next rotation would never reach it. If you do want a plain value, say so: in the dashboard’s Edit dialog, choose Convert to plain value.
Copying across environments is the one place a composition becomes a plain value automatically. When you copy a composed secret to another environment, from the comparison view or from the suggestions of secrets missing in an environment, the destination gets the resolved value as a plain secret, because its references may not exist there. The suggestions mark such a secret copies as plain text. Duplicating a whole environment keeps compositions composed.
Rotating
Rotation is where composition pays for itself. Change the secret that others reference:
varsafe set DB_PASS --stdin < new-password.txtEvery composed secret that references DB_PASS now resolves to the new value. No other secret changed and nothing needed finding, which is the whole point: the failure mode without composition is missing one of the copies.
You can also rotate the referenced secret from the dashboard’s Rotate action. Rotating a composed secret itself is refused: a composition has no value of its own, so rotate what it points at instead.
Deleting a secret that others reference
varsafe refuses, and asks what should happen to the secrets built from it:
$ varsafe unset DB_PASS
✖ Other secrets are composed from this one. Choose whether to keep them working or delete them too
There are two answers, named by their consequence:
varsafe unset DB_PASS --flatten # DATABASE_URL keeps working; the value is copied into it
varsafe unset DB_PASS --cascade # DATABASE_URL is deleted too
Where each answer shows up:
--flatten |
--cascade |
|
|---|---|---|
| Dashboard | Keep them working | Delete them too |
--json output |
flattened |
alsoRemoved |
| Audit log, per dependent | secret.updated with flattenedFrom |
secret.deleted with cascadedFrom |
| An AI agent’s reply | flattened |
alsoDeleted |
There is deliberately no default. A CI job that chose for itself would either spread a compromised value or take a service’s configuration away, with nobody in the loop.
The dashboard asks the same question. Its delete dialog becomes Other secrets depend on this, lists them, and offers Keep them working or Delete them too.
--cascade follows the chain: if REPORT is built from APP_CONFIG and APP_CONFIG from DB_PASS, all three go. Stopping partway would leave a reference to something that no longer exists.
The set is decided at the moment of the delete, not when you looked. If someone adds a secret referencing DB_PASS while you are deciding, it is deleted too: cascade means “this key and everything that depends on it”, and what depends on it is a fact about the environment at that moment.
So the result always names what actually went:
varsafe unset DB_PASS --cascade --json{
"key": "DB_PASS",
"removed": true,
"alsoRemoved": ["APP_CONFIG", "DATABASE_URL"],
"flattened": [],
"project": "my-project",
"environment": "production"
}
Without --json the same information is printed as a warning. With --flatten, flattened lists the secrets the value was copied into; those are plain values now, not references.
Deleting several secrets at once checks the whole batch, so tearing down a service’s interdependent configuration in one go raises nothing. It refuses only when a secret outside the batch would be left pointing at one inside it.
Auditing
Reading one composed secret discloses every secret inside it. So the audit log records the whole set, not just the key you asked for: reading DATABASE_URL is logged as access to DATABASE_URL, DB_USER and DB_PASS. A compromise review that saw only the requested key would clear a password that had in fact been read.
That is one reason for the 64-secret limit per read: it keeps the set small enough that every name is always recorded in full.
Deleting a secret others are composed from is recorded secret by secret. The deleted secret’s secret.deleted says what was done to the secrets composed from it (disposition: cascade or flatten); each secret a cascade deleted has its own secret.deleted naming the one deleted (cascadedFrom), and each secret a flatten rewrote has its own secret.updated (flattenedFrom). Rolling the environment back to before the deletion restores all of them, the compositions included, and the same holds when an AI agent deleted the secret: its reply lists what it deleted (alsoDeleted) and what it rewrote (flattened).
A deletion never runs over a write still being finished: if a secret it would delete or rewrite has one, which takes seconds, the deletion is refused with nothing changed, and trying again is safe.
What composition is not
- Ordinary values are never expanded. A plain secret containing
${DB_PASS}stays exactly those characters, on every read. Only a secret explicitly created as composed is resolved. - Nothing was reinterpreted. Secrets that existed before composition are plain values, even if one happens to look like a template.
- There is no shell interpolation.
varsafe runstarts your command without a shell, so$VARin the command is not expanded either.
This matters if you store templates as data, such as Grafana dashboards, Kubernetes manifests or another tool’s configuration. Pasting ${DS_PROMETHEUS} into a plain value stores that text and returns that text:
varsafe get DOCS_SNIPPET_TEMPLATEYAvailability
Composition is on for every team on varsafe.dev, on every plan. A CLI too old to understand composed secrets is refused when it tries to overwrite one rather than flattening it by accident; varsafe update fixes that.
If you point the CLI or dashboard at a varsafe deployment that has composition switched off, the dashboard does not offer Composed, and an existing composition keeps resolving but cannot be edited as one. The edit dialog says so and offers to convert it to a plain value.