3.5 Secrets and the vault
Included from Basic tiersecrets:multi-vault
After this you can: store a secret in the vault, reference it from properties with secret-vault://, see it masked in a run's log, use the local secret store on the local lane, and source a CI job's secrets with secret-env://.
Before you start — tick each one:
- Lesson 3.1 — Your first change (
opschain learn 3.1) - Lesson 1.3 — Properties and settings: the overlay (
opschain learn 1.3)
In the GUI: the Secrets providers panel in the Operations area — provider connections and scope bindings, never values.
A secret never belongs in a properties document, an action file or a
shell history line. In OpsChain the secret's value lives in the
server's secret vault, and everything else — properties, step contexts,
logs — carries only a reference to it, resolved at the moment a step
runs. This lesson stores one value, references it from lesson 1.3's
properties, watches a run use it without ever printing it, and places
the local secret store beside the vault so the two never get confused.
1. Store a secret in the vault
opschain vault store acme/environments/dev \
--path secret-vault://dev/db_password \
--value-env DEV_DB_PASSWORD
stored secret at secret-vault://dev/db_password
stored value (cipher-stacked): {AES2}…
Three rules do the heavy lifting:
- The value never goes on the command line.
--value-envnames an environment variable holding it; interactive sessions can use--value-prompt(read without echo), and--value-filereads a file. Omit all three and the server generates a value for you (the first line then readsstored generated secret at …). - The node path scopes the write. The first argument names the vault
owner — storing needs permission to update that node's secrets path (or
/secrets). The owner does not change where the value lives: thesecret-vault://<path>/<key>URL is the value's address. - What comes back is never the plaintext. The server returns the
stored value cipher-stacked: its own AES layer (the
{AES2}-prefixed blob) applied on top of the vault backend's at-rest encryption, governed by the enabled secrets provider'scipher_stackingoption (default on). A provider configured to store values without the stacked layer returns a placeholder saying so instead.
The vault refuses to overwrite an existing value — re-running the command
is a named error until you pass --replace, which is how rotation stays a
deliberate act. Whole files (an SSH key, a kubeconfig) go in with
vault store-file; binary content is Base64-encoded by the server, text
is stored as-is:
opschain vault store-file acme/environments/dev \
--path secret-vault://dev/deploy_key -f ~/.ssh/id_deploy
stored secret at secret-vault://dev/deploy_key
stored value (cipher-stacked): {AES2}…
Which vault answers depends on the server's enabled secrets-provider
connection (opschain admin secrets providers; the guide is
opschain learn secrets-providers): an OpenBao / HashiCorp Vault KV
engine in production setups, or the server's own encrypted file store
when no provider is registered — the commands and references are
identical either way. Under multi-vault (the secrets:multi-vault
entitlement) an estate holds several enabled providers, with scope
bindings (opschain admin secrets bindings) routing resolution by node
position: an environment's binding beats its project's, which beats the
estate default. Which node's binding serves a reference follows where
the value came from: a reference committed in the template's
repository resolves through the binding of the node running the
change, so the SAME committed reference resolves against the payments
vault in payments-prod and the shared vault in dev with zero reference
churn; a reference entered in a node's properties (project,
environment or asset) resolves through the binding of the node it was
entered on, so a project's shared secret keeps reading from the
project's vault under every environment, bound differently or not. Two
spellings override that default: secret-vault://origin@<path>/<key>
always follows the node whose properties supplied the value,
secret-vault://here@<path>/<key> always follows the node resolving
it. The rare cross-provider read names its provider explicitly —
secret-vault://<provider-code>@<path>/<key> — the escape hatch, never
the common path, and it wins over every rule. opschain admin secrets bindings resolve <node-path> --origin <node-path> shows all the forms
side by side for a pair of nodes.
Writes never follow the bindings: an unqualified vault store /
vault store-file URL always lands in the platform's default provider,
and the provider-qualified form writes through the named provider —
authenticated as you, with your stored credential for that provider. An
unknown or disabled provider code, or a missing personal credential, is a
named refusal: a qualified write is never silently redirected to a
different vault.
2. Reference the secret from properties
A reference is a property value that is, in its entirety, the secret's URL:
{
"opschain": {
"env": {
"DB_PASSWORD": "secret-vault://dev/db_password"
}
}
}
Store that on the environment (opschain properties edit acme/environments/dev -f props.json — lesson 1.3) and every change under
it carries the reference, not the value. Just before each step executes,
the server resolves every reference in the step's converged properties
and registers the resolved values with the log masker — so a
opschain.env entry lands in the step's environment as the real value,
while logs, events and the stored properties only ever show
secret-vault://dev/db_password. Run a change whose step echoes the
variable and read its log:
opschain logs <run-id>
[db:connect/check] connecting with password ******
The same goes for file-type properties (opschain.files): the reference
stands in for the file's content until the step materialises it.
Two syntax facts worth knowing, straight from the resolver:
- The reference must be the whole value —
secret-vault://…embedded inside a longer string is not resolved. - The URL carries a path and a key, split on the last slash:
secret-vault://dev/db_passwordis pathdev, keydb_password, andsecret-vault://acme/tf/api_tokenis pathacme/tf, keyapi_token.
Because the reference is a pointer, not a secret, it is safe wherever
properties appear — properties show, runs converged (lesson 1.3) and
exports all show the reference itself.
3. Encrypt a value for direct use
vault encrypt cipher-stacks a value with the server's keys and prints
the ciphertext — for settings or properties fields that accept encrypted
values directly:
opschain vault encrypt --value-prompt
{AES2}…
It needs the update right on /secrets; the plaintext never echoes, and
a blank value or a secret-vault:// reference comes back unchanged.
4. The global read, and its guards
vault global is the read lane, deliberately awkward: it decrypts one
vault value only for a caller who can already prove the stored
(encrypted) form — pass the vault store output back via
--expected-env — and is gated on /secrets permissions:
opschain vault global acme/environments/dev \
--path secret-vault://dev/db_password --expected-env STORED_VALUE
The decrypted plaintext is the command's stdout, because it is the requested output — redirect accordingly, and prefer references over reading values back at all.
Two further guards sit on the interactive read lane, because reading a secret back is the sensitive act:
- Vault reads ride YOUR credential, never the platform's. On an
estate whose secrets live in a registered provider (OpenBao/HashiCorp
Vault), the decrypt and global reads authenticate to the vault as you —
the vault's own ACLs decide what you may read. Store your credential
once (
SecretService.SetUserVaultCredentialonopschain docs api-v2, or the GUI secrets tool's credential setting); without one the read refuses with a typed error telling you so. Platform resolution (references into steps) is unaffected. The local dev file store needs no credential. - Decrypt-for-display sits on the step-up floor: an interactive
session must be elevated (
opschain elevate, a second-factor prove-up — lesson 7.4) before the decrypt verbs serve, and every decrypt and vault read is audited — the event stream records who read which address, never the value.
5. Secret-typed run inputs
Typed run inputs (lesson 4.1) have a secret type, and it speaks the
same reference syntax end to end. Declare it in the workflow:
inputs:
api_token: { type: secret, required: true, description: "vault reference to the API token" }
steps:
- type: change
name: deploy
target: /projects/acme/environments/dev/assets/web
action: web:deploy
with:
token: ${{ inputs.api_token }}
Supply it at start — the value must be a secret-vault:// reference;
a raw secret value is refused before the run exists:
opschain runs create workflow acme/release -i api_token=secret-vault://dev/api_token --follow
And watch the masking hold everywhere the run is written down: the run
document records the input as ******, a --plan payload masks it
before evaluating, condition-verdict operands show ******, and the
reference resolves to the real value only inside the executing step —
where the log masker already knows it:
opschain runs show <run-id> -o json | grep -A2 '"inputs"'
"inputs": {
"api_token": "******"
},
The pointer travels; the value appears only at execution, already registered for masking. That is the whole pattern.
6. The local secret store
opschain secret set|get|ls|delete (lesson 2.1's world) manages a
different thing: a per-user, per-machine store for local runs — the
--local lane where no server is involved. Local runs resolve the same
secret-vault://<path>/<key> references through that store, so an action
authored against the vault works unchanged on your laptop; the store's
master key sits in your OS keychain (or a key file with --no-keychain).
Nothing in it ever reaches a server, and vault never touches it — the
two stores share a reference syntax, not data.
One more thing the vault is not for: the server's own integration
credentials. The API key you give a model provider, the client secret of
an identity provider, a GitHub App key, a JSM token, an image registry or
build provider credential, a transport provider's SSH key and the licence
installation credential are held encrypted at rest in the server's
database, on the record itself, when you supply them inline — never
written into a secrets provider. A PostgreSQL server with no secrets
provider registered still registers every one of them; a secrets
provider is needed only to resolve the secret-vault:// references your
changes use, and to pin a --credential-ref on such a record.
7. Secrets from the environment — the CI pattern
A CI job keeps its secrets in the job's environment, not in a vault
OpsChain can reach. The local lane has a second reference scheme for
exactly that: secret-env://<NAME> reads the named variable of the
process running opschain run (or runs create … --local), at run
preparation, before anything runs. It works wherever a secret-vault://
reference works — a property value, a secret-typed run input — and it
is masked the same way: the value joins the run's mask set the moment it
resolves, so a step that prints it logs ******.
# .opschain/properties/projects/acme/environments/ci/properties.json
{ "db": { "password": "secret-env://DB_PASSWORD" } }
DB_PASSWORD="$CI_DB_PASSWORD" opschain run web:deploy --env ci -i api_token=secret-env://API_TOKEN
An unset variable is a named failure before any step runs
(resolving secret reference "secret-env://DB_PASSWORD": the environment variable DB_PASSWORD is not set in this process's environment). The
scheme belongs to the local lane only: a server refuses it by name
wherever it appears — a property document at dispatch, a run input at
creation — because a server never reads its own process environment for
a secret. Nothing else of your shell reaches a step either: pass a
non-secret variable with --env-passthrough NAME instead.
8. One read per run, and declaring a rotation
The platform reads each reference from the vault once per run, at the first step that needs it, and every later step of the run reuses the value from a run-scoped snapshot that lives only in the resolving process's memory and is dropped at run end — a run sees one consistent view of the vault, and a vault administrator sees one read per path per run instead of one per step. A rotation done outside the run is seen by the next run. A step that rotates a secret itself says so, and the run moves on with it:
steps:
- name: rotate-db-password
run: ./rotate.sh
secrets_written:
- secret-vault://db/password
- name: restart-app
run: ./restart.sh "${{ properties.environment.db.password }}"
At rotate-db-password's successful completion the platform bumps the
reference's epoch for the run, so restart-app — on this runner or any
other runner of the run — re-reads secret-vault://db/password instead
of reusing the snapshot. refresh_secrets: true on a step is the blunt
override: every reference that step carries is re-read from the vault.
Both are run-step fields; the reference page (opschain docs secrets-providers, "How steps consume it") has the full economy — the
run-scoped vault lease, the per-provider rate budget and the circuit
breaker — and the run's evidence carries one vault traffic: line
summing what the run cost the vault.
Note for declarative steps: only the references a step's command,
inputs and ${{ properties… }} reads carry are resolved for it; a
reference the step never reads stays a reference in its step context.
Check yourself
- For the change that used your reference: what does
opschain runs converged <run-id>show foropschain.env.DB_PASSWORD, and what does the log print where the value was used? - Run a workflow whose
api_tokeninput is a vault reference: doesopschain runs show <run> -o jsonrecordapi_tokenas******, and did the run succeed? The Academy'svault-referenceexercise readsapi:workflow_runs:createthenapi:workflow_runs:successon your project and that masked input — no event records a vault write, so the masked input is the evidence that the reference travelled. - Verified version:
opschain academyruns thevault-referenceexercise against this lesson.
What you learned
vault store/store-fileput values in the server's vault: values arrive via env var, no-echo prompt or file (never argv), writes are scoped by the owner node's secrets permissions, overwrites need--replace, and the server only ever returns the encrypted form.- Properties carry
secret-vault://<path>/<key>references — whole-value strings, path/key split on the last slash — resolved into the step just before it runs and masked in its logs. vault encryptproduces ciphertext for direct use;vault globaldecrypts one value only against proof of the stored form, as you, on the step-up floor, audited.- A
secret-typed run input takes vault references only, records as******in every run document and plan, and resolves only inside the executing step. Leaving an optional secret input unset (or empty) means "not supplied" — its default applies, or it binds the empty string and records as""— so a workflow can pass${{ inputs.webhook }}to a change step whether or not the caller set it, andinputs.webhook != ''tests whether it was; only a non-empty value that is not a vault reference is refused. - The local
secretstore is the--locallane's resolver for the same references: per-user, on your machine, separate from any server. secret-env://<NAME>reads a secret from the running process's environment in the local lane only — masked like any other secret, named when unset, refused by name on a server.
Next: lesson 3.6 — Artifacts (opschain learn 3.6), or back to your track (opschain learn).