DOC_INDEX
THEME
DOCS/Learn OrchStep/Secrets

Secrets

Resolve secrets from your own tool (Vault, SOPS, 1Password, cloud) and reference them with the secrets namespace. The real value reaches the tool while logs, output, and the run history stay masked.

OrchStep is a conduit for secrets, never a vault. It does not store your credentials, encrypt them at rest, or ask you to paste them into YAML. Instead it resolves a secret from whatever tool you already use at the moment it is needed, hands the real value to the tool that needs it, and guarantees the value never leaks into logs, command output, or the captured run history.

The mental model

{{ secrets.X }} is a third namespace alongside {{ vars.X }} and {{ env.X }}:

{{ vars.X }}{{ env.X }}{{ secrets.X }}
What it isconfig the workflow consumesOS env for external toolsa credential fetched from your tool
Stored in the run history?yesyes (unless masked)never
Shown in logs/output?yesyes (unless masked)never (always ***)
Resolvedat loadat loadlazily, once, on first reference

A secret is always a reference, never a literal - you cannot hardcode one in YAML. That is the point: the value lives in your secret manager, and OrchStep just runs the command to fetch it.

secrets: - declare where a secret comes from

Declare secrets: at the workflow, task, or step level. Every entry is a resolver, not a value:

secrets:
  DB_PASSWORD: { cmd: "op read op://prod/db/password" }        # 1Password CLI
  API_TOKEN:   { cmd: "vault kv get -field=token secret/api" } # HashiCorp Vault
  AWS_SECRET:  { cmd: "aws secretsmanager get-secret-value --secret-id db --query SecretString --output text" }
  GH_TOKEN:    { env: GITHUB_PAT }                             # promote + consume (see below)
  REDIS_PW:    "op read op://prod/redis/password"              # bare string = cmd

When you promote with env:, OrchStep consumes the source variable by default: after capture it unsets GITHUB_PAT from the process and from every child command, so the only way to read it is the masked {{ secrets.GH_TOKEN }}. A raw, untracked env var can no longer leak. Opt out per secret with { env: GITHUB_PAT, consume: false }, or flip the default for the whole workflow:

config:
  env_policy:
    consume_promoted: false   # default is true (consume)

Three producer forms - a secret is the tainted output of a producer:

  • cmd: runs the command and uses its trimmed stdout as the secret. Any secret manager works because they all ship a CLI - Vault, SOPS, 1Password, AWS Secrets Manager, gcloud secrets, gpg, pass, your own script. OrchStep does not need a plugin for each one.
  • env: promotes an existing OS environment variable (e.g. one your CI injected) into a tracked secret.
  • task: runs one of your own tasks (or an imported module) as the producer - full engine power: an auth step first, retries, error handling, modules. See below.

Auth for your secret tool

Most secret CLIs need a token to authenticate (VAULT_TOKEN, VAULT_ADDR, OP_SERVICE_ACCOUNT_TOKEN, AWS creds). A cmd: resolver runs with the workflow's full environment - the host env, anything from dotenv:, your env: values, and other already-resolved secrets - so just provide the auth the way you provide any other variable:

dotenv: [.vault.env]               # VAULT_ADDR=... VAULT_TOKEN=...
secrets:
  DB_PASSWORD: { cmd: "vault kv get -field=password secret/db" }

The auth token can itself be a secret:

secrets:
  VAULT_TOKEN: { env: CI_VAULT_TOKEN }
  DB_PASSWORD: { cmd: "VAULT_TOKEN={{ secrets.VAULT_TOKEN }} vault kv get -field=pw secret/db" }

Producers that need auth, or return many values

If you have several secrets in one place, you do not repeat the command. Fetch once and pull each field - identical commands run only once per run:

secrets:
  DB_USER: { cmd: "vault kv get -format=json secret/app", field: "{{ result.data.data.user }}" }
  DB_PASS: { cmd: "vault kv get -format=json secret/app", field: "{{ result.data.data.pass }}" }

For anything more involved - a login step, retries, a reusable module - make the producer a task. It runs through the normal engine, once, and field: pulls values from its step outputs:

tasks:
  vault-creds:
    steps:
      # Keep the login and the read in ONE step so they share a shell session.
      # Each step is its own process, so a `vault login` in a separate step would
      # not carry to a separate `vault kv get` unless your tool caches to disk.
      - name: fetch
        func: shell
        do: |
          vault login -method=aws >/dev/null
          vault kv get -format=json secret/app
        outputs:
          user: "{{ result.output | fromJson | dig \"data\" \"data\" \"user\" \"\" }}"
          pass: "{{ result.output | fromJson | dig \"data\" \"data\" \"pass\" \"\" }}"

secrets:
  DB_USER: { task: vault-creds, field: "{{ steps.fetch.user }}" }
  DB_PASS: { task: vault-creds, field: "{{ steps.fetch.pass }}" }   # task runs once, not twice

A producer task's output is never displayed - it is secret material by definition. (Steps are isolated processes, so keep an auth step and the read it enables in one do:; see Shell Execution.)

Use a secret

Reference {{ secrets.NAME }} anywhere a template works - most often an env: value handed to a tool:

secrets:
  DB_PASSWORD: { cmd: "op read op://prod/db/password" }

tasks:
  migrate:
    env:
      PGPASSWORD: "{{ secrets.DB_PASSWORD }}"   # real value -> psql
    steps:
      - { name: run, func: shell, do: "psql -f migrate.sql" }

psql receives the real password. But in the run log, the captured history, and any output, it appears only as ***.

Secrets resolve lazily - a secret nobody references is never fetched. So a secrets: block declared at the workflow level costs nothing during a task that does not use it, and a dev run that never touches production needs no production credentials. This applies to producers too: a task: or cmd: producer runs only on first reference, once - declared-but-unused producers never execute, and a producer shared by several secrets runs a single time (its result is cached for the rest of the run).

What is guaranteed

SurfaceWhat you get
The tool that needs itthe real value (masking never changes what the tool receives)
Command echo in logsmasked (psql ... ***)
Command outputmasked - even if a tool prints the secret itself, it is scrubbed to ***
--dry-run planshows ⟨secret:NAME⟩; the resolver is never run, so planning needs no credentials
The context store (run history)the secret is excluded entirely

That third row is the strong one: once a value is known to be a secret, OrchStep replaces that exact string with *** anywhere it appears - the same guarantee CI systems give for registered secrets.

secrets:
  TOKEN: { cmd: "printf 'abc123'" }
tasks:
  main:
    steps:
      - { name: leak, func: shell, do: 'echo "token={{ secrets.TOKEN }}"' }
$ orchstep run
  $ echo "token=***"
token=***

Requiring vs. providing

If a workflow references {{ secrets.NAME }} that is not declared in scope, the run fails fast with a clear error before the step executes:

Error: secret "NAME" is referenced but not declared in scope (add it under secrets:)

Should you put secrets in env: instead?

For a value that is genuinely a credential, prefer secrets: - it gets masking and is kept out of the run history. Plain env: values are visible unless their name matches a built-in sensitive pattern (*token*, *secret*, ...). See Environment Variables for the env: / dotenv: surface and name-based masking.

One caveat OrchStep cannot fix for you: a secret interpolated directly into a shell command is on that process's argument list, which other users on the machine can see via ps. Prefer handing secrets to tools through env: (as in the PGPASSWORD example) rather than as --password=... arguments.

Where to go next