Secret references
A secret's value can reference another secret:
seekrit secrets set DB_HOST db.internal --env production
seekrit secrets set DB_PASSWORD 's3cr3t' --env production
seekrit secrets set DATABASE_URL 'postgres://app:${DB_PASSWORD}@${DB_HOST}:5432/app' --env production
At read time, every consumer sees the assembled value:
seekrit export --env production
# DATABASE_URL=postgres://app:s3cr3t@db.internal:5432/app
# DB_HOST=db.internal
# DB_PASSWORD=s3cr3t
Nothing is duplicated. Rotate DB_PASSWORD and DATABASE_URL follows on the
next read — no re-encryption, no second write, nothing to forget.
How it works (and why it stays zero-knowledge)
The reference is stored literally. What seekrit holds is the ciphertext of
the text postgres://app:${DB_PASSWORD}@${DB_HOST}:5432/app — the server can no
more read that than any other secret, and it never resolves anything.
Expansion happens on the client, after the layers are merged, in the same process that decrypted the values:
group secrets < app-env secrets < .env file (merge, then expand)
Two consequences worth knowing:
- A reference resolves to whatever won the merge. If a group defines
DB_HOSTand the app environment overrides it,${DB_HOST}picks up the app's value — references compose with groups rather than fighting them. process.envis not a reference source.seekrit runlayers the live shell on top after expansion, so${HOME}in a secret is not replaced by the host's home directory. This keeps a secret's value the same wherever it runs.
The rules
| You write | You get |
|---|---|
${NAME} | The value of NAME in the merged set. |
${NAME} where NAME is not defined | Left exactly as written, and reported (see below). |
$${NAME} | The literal text ${NAME} — the escape. |
${FOO:-default}, ${1}, ${a.b} | Left exactly as written: only valid secret names ([A-Za-z_][A-Za-z0-9_]*) are references, so shell and CI template syntax passes through. |
p$$w0rd$ | Unchanged. A $ only starts a reference when followed by {. |
References are recursive — a referenced value may reference others — and a
cycle is an error (A → B → A has no answer, and every name in it
exists, so it can only be a mistake).
An unknown name is deliberately not an error: plenty of real config contains
text like ${GITHUB_SHA} meant for something else downstream, and one such value
shouldn't break an environment's resolve. To catch typos, ask:
seekrit run --explain -- ./app
# DATABASE_URL app:api/production (interpolated)
# DB_HOST group:common@production
# DB_PASSWORD app:api/production
#
# unresolved reference(s), left as literal text: DB_HSOT
Where it applies
Expansion is part of the read path, so it happens everywhere secrets are read — each of these does it locally, and none of them needs a new permission:
| Reader | Notes |
|---|---|
seekrit run / seekrit export | Expanded after merging; --no-interpolate opts out. |
seekrit secrets get NAME | Expanded against that environment's own secrets — it doesn't fetch the composed group layers. --raw prints the stored text, and so does --version <n> (splicing today's values into an old template would be misleading). Use seekrit export for the fully-layered view. |
seekrit-run launcher | Same behavior and the same --no-interpolate flag. |
| Language SDKs | resolve() returns expanded values; construct the client with interpolate: false (Python interpolate=False, Go WithInterpolate(false)) for the stored text. |
| Agent egress proxy | Expanded at startup, so a {{seekrit:NAME}} placeholder is substituted with the finished value. |
| Kubernetes (ESO) | The in-cluster resolver expands before ESO writes the Secret. |
| MCP servers | run_command, export_env, and a revealed get_secret all expand. |
| Dashboard | Shows the value as stored; the editor tells you which names a value references. |
Because expansion is client-side, a reader that predates this feature simply sees the literal text — nothing breaks, it just isn't assembled.
When not to use it
- Don't reference across environments. References resolve inside one resolved environment. A staging secret cannot pull from production — by design, since that would cross a key-grant boundary.
- Careful with
secrets get NAME | secrets set NAME -. That would store the expanded text and freeze the reference. Pass--rawwhen you are copying a value around. - A password that happens to contain
${is almost always fine: only${VALID_NAME}is a reference, and only when that name exists in the same environment. If a stored value really does contain such a sequence and must survive verbatim, escape it as$${…}.