Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Environment Variables

Orion reads the process environment four ways, and which one applies is decided by where the value sits, not by what the value is. The same value written into a config file, a connector and a workflow is not read the same way in all three.

MechanismSyntaxWhere you write itRead
Setting overrideORION_SECTION__KEYthe environment itselfonce, at startup
Text substitution${VAR}, ${VAR:-default}, $$the config file, a connector configbefore the text is parsed
Secret referenceenv://VAR, vault://…selected parsed string fieldsat every load and reload
Declared value[vars] / [secrets]the config file, read by name from a workflowonce, at startup

The first belongs to Configuration Reference: every setting has an override, and that page names them all. The rest are this page’s subject. They are not interchangeable — ${VAR} rewrites text before it is parsed, env:// rewrites a parsed value after — which is why both reach a connector and only one reaches a config file.

The fourth is different in kind: it does not rewrite anything. The operator declares a name in the config file, and a workflow reads that name. Which is why it is the one to reach for when a workflow needs a value that varies by environment — see Values a workflow reads by name.

Where a reference resolves

env:// is not a template pass over your JSON. Each reference is resolved by the code that reads one particular field, so it works where a resolver is wired in and nowhere else:

Surface${VAR}env://VAR
Config file passed with -cyesno
Connector config — any string field, at any depthyesyes
Channel authkeys, secret, secrets, jwt_keys[].keynoyes
Workflow task cryptokeynoyes
Workflow task jwt_signkeynoyes
Workflow task jwt_verifykeys[].key, issuer, audiencenoyes
Everything else in a workflownono

A workflow carrying one anywhere else is refused: POST /workflows, POST /workflows/validate and orion-server lint all report UNRESOLVED_SECRET_REF naming the field, because env:// at the head of a string has no reading in which it is data. Before that check existed the reference was simply a string — a task with "path": "env://API_BASE" requested a URL spelled env://API_BASE and failed with whatever the backend made of it.

Warning

${VAR} has no such guard, because ${…} is ordinary text in a query or a template. A workflow embedding ${TENANT} sends those nine characters to the backend. Nothing substitutes them.

The three workflow entries are exceptions with a reason: a signing key has nowhere else to live. Everything else that varies between environments belongs in a connector, or — since 1.3 — in [secrets], which those same three fields also read. The workflow names the connector or the secret, the value lives outside the definition, and the workflow document is then byte-identical in dev, staging and production, which is what makes a package promotable at all.

vault://<api-path>#<field> resolves wherever env:// does, reading HashiCorp Vault when VAULT_ADDR and VAULT_TOKEN are set. The cloud schemes aws-sm://, gcp-sm:// and azure-kv:// are reserved: a reference using one is refused rather than handed to the backend as a literal credential. The forms are in Connector Types › Secrets by reference.

Values a workflow reads by name

A reference resolves in five workflow fields and a channel’s auth block. Everything else a workflow needs from the environment it reads by name, from one of two config sections the operator declares:

[vars]
kafka_topic_prefix = "${KAFKA_TOPIC_PREFIX:-dev}"

[secrets]
partner_hmac = "env://PARTNER_HMAC_KEY"
{ "topic": { "cat": [{ "var": "metadata.vars.kafka_topic_prefix" }, "order.placed"] } }
{ "op": "hmac", "key": { "secret": "partner_hmac" }, "data": { "var": "data.body" } }

The two are the same declaration model with opposite trace contracts, and that is the whole distinction:

[vars][secrets]
A workflow reads it as{"var": "metadata.vars.<name>"}{"secret": "<name>"}
Where it lives at runtimestamped into every message’s metadataheld by the engine, never in a message
In tracesyes, deliberatelyno, structurally
Values must beliteralsenv:// / vault:// references
Resolves inany workflow expressionany workflow expression, plus the five secret-bearing function fields above

Vars are recorded on purpose. An operator asking “which topic did this run publish to?” is asking to see them, and a deployment constant hidden from the trace makes that question unanswerable. They are stamped at every ingress — HTTP and Kafka alike — over whatever the caller sent, so an envelope-mode request cannot name its own topic prefix, and they keep the type they were written as.

Secrets are unrecordable rather than redacted. The store belongs to the engine, not to the message, so a secret cannot appear in a trace snapshot, a map mapping clone or a response body — there is nothing to strip. The engine enforces the two ways that could go wrong: a workflow that reads a secret where the result would be recorded (a map mapping, a log field) is refused when the engine is built, as is one naming a secret the instance does not declare. Both surface as a quarantined channel naming the reason.

Each section refuses the other’s value shape, because either mistake is silent. A literal in [secrets] is a key in the deployment’s file tree; a reference in [vars] reaches the workflow as the characters env://PARTNER_HMAC_KEY, because nothing resolves one on the way into metadata.

Note

{"secret": …} resolves in workflow expressions only. A channel’s validation_logic and key_logic compile on an engine with no secret store, so a reference there fails to compile and the channel is quarantined. Channel authentication takes env:// instead.

Offline, there is no config file to read either section from. orion-server dry-run --secrets and a *.case.json secrets block supply stand-in values; metadata.vars is written directly into the case’s metadata. See Test Workflows Offline.

Author every credential as a reference

{ "name": "orders-db", "config": { "type": "db", "connection_string": "env://ORDERS_DB_URL" } }
{ "config": { "auth": { "mode": "hmac", "secret": "env://STRIPE_WEBHOOK_SECRET" } } }

The stored row holds a variable name, so a database dump is not a leak and one document deploys everywhere. A literal credential is worse than untidy: API reads mask it as "******", and an import carrying "******" is refused — so a connector authored with a literal cannot be promoted between instances at all. The table of what survives an export is in Promote Between Environments.

For the three workflow fields there is a second reason. Connector configs can be encrypted at rest with storage.connector_encryption_key; workflow documents cannot. A literal key in a crypto or jwt_sign task sits in the workflows table in clear, and every version of that workflow keeps it.

Two habits close the gap that references alone leave open:

  • Keep credentials out of URLs. Use a connector’s query_params rather than embedding a resolved value in the endpoint. A URL reaches error messages, logs, spans and trace rows; the redaction that masks it there matches conventional parameter names and is a backstop, not the control.
  • Encrypt what is left. connector_encryption_key = "env://ORION_SECRET_CONNECTOR_KEY" puts an AES-256-GCM envelope around stored connector configs, so a dump carries neither the credential nor a readable config. See Secure an Instance.

Rotate without downtime

Every credential Orion checks on the way in accepts more than one value, so rotation is two deploys instead of a flag day. Add the new variable, deploy, cut clients over, then remove the old one:

CredentialThe listSyntax per entry
Channel api_keyauth.keys — any match authorizesenv://
Channel hmacauth.secret plus auth.secrets — each tried in constant timeenv://
Admin APIadmin_auth.api_keys${VAR} in the config file, or the whole list through ORION_ADMIN_AUTH__API_KEYS

A vault:// reference is re-read on every reload, so a renewed token applies without a restart. An env:// value is read from the process environment, which a running process cannot change — rotating one means restarting or redeploying the server.

Naming the variables

Name them anything, with one restriction: Orion refuses to start on an ORION_* variable that follows the override grammar without being one of its settings, so a typo costs a boot rather than a week of a silently ignored setting. A secret that must live in that namespace needs the reserved prefix — env://ORION_SECRET_STRIPE_API_KEY — which Orion never reads as configuration. The exemptions, and why the scan looks at the __ rather than the ORION_, are in Configuration Reference › Misspellings are startup errors.

What an unset variable does

Where the reference sitsIf the variable is not set
Config file, ${VAR}Startup fails, naming the file and the position.
Config file, ${VAR:-default}The default is used. An empty default is legal.
Config file, [secrets]Startup fails, naming the entry and never its value. The alternative is an instance that runs and fails at the remote system with nothing pointing back here.
Connector configThat connector is skipped at load. The server still starts and every other connector serves; /health reports components.connectors: degraded, and the connector’s row in GET /api/v1/admin/connectors carries load_status: "failed" with a load_error. Every task using it fails.
Channel authThe channel is quarantined — refused at every ingress rather than served with the guard missing. /health reports components.channels: degraded.
Workflow crypto / jwt_sign / jwt_verifyThe task fails when it runs, with a validation error naming the field. Nothing catches it earlier: the value is read at execution.
A workflow field that resolves nothingRefused at create, update and lint with UNRESOLVED_SECRET_REF — the variable is never consulted, because the reference would never have been resolved.
A workflow naming an undeclared {"secret": …}The engine refuses the workflow, and the channel is quarantined rather than served with the key resolving to null.
A reserved schemeRefused wherever it is resolved, so an unresolvable reference never reaches the remote system as its own text.

Creating or updating a connector does not resolve its references — POST /connectors checks the config’s shape, not this host’s environment — so a connector authored on a laptop is accepted there and reveals the missing variable at load. POST /connectors/validate reports an unresolvable reference as a warning for the same reason: a CI runner holds no production secrets and must still be able to check a bundle. Neither /health line is a 503; both are the degraded-but-serving state described in Troubleshooting.

Inventory what a set needs

orion-server lint ./definitions ends with one note: per secret the set references, naming each one and the files that mention it: [env.reference] for each variable an env:// reference needs in the environment, and [secrets.reference] for each name a {"secret": …} needs in the serving instance’s [secrets] section. Both are exit-neutral — neither the exit code nor --deny-warnings counts a note — because the machine running lint is not the machine that will serve the set, so its environment and its config say nothing about whether the value will be present where it matters.

The scan is textual: it walks every string in every definition, so it reports what a set mentions. A reference in a workflow field that resolves nothing no longer hides among them — lint fails the set with [env.unresolved] before the inventory is worth reading — but a connector or channel field is still inventoried without any claim that the variable will be set where it matters.

Supplying them

With Compose, the container takes them as ordinary environment entries alongside the ORION_* overrides:

environment:
  ORION_STORAGE__URL: "postgres://user:pass@db:5432/orion"
  ORDERS_DB_URL: "${ORDERS_DB_URL:?set this}"
  STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:?set this}"

With the Helm chart, extraEnv renders verbatim into the container’s env:, so it carries connector and channel secrets as well as ORION_* overrides, and extraEnvFrom maps whole Secrets:

extraEnv:
  - name: ORDERS_DB_URL
    valueFrom:
      secretKeyRef: { name: orion-connectors, key: orders-db-url }
extraEnvFrom:
  - secretRef: { name: orion-connector-secrets }

orion-server validate-config prints the merged configuration without starting, which checks the config-file half: secret-looking keys come back ****** and passwords inside URL-shaped values are struck out in place, so the output is safe to paste into an issue. It says nothing about connectors and channels — those live in the database and are resolved at load, so /health and the connector list are what report them.