Environment Variables
Set OS environment variables with env:, load .env files with dotenv:, require inputs with the <required> sentinel, and keep secrets masked - all declarative.
First, the mental model that prevents 90% of confusion. OrchStep has two namespaces that never mix:
{{ vars.X }} | {{ env.X }} | |
|---|---|---|
| What it is | OrchStep variables (config the workflow consumes) | OS environment variables (for external tools) |
| Set by | defaults:, task/step vars:, --var, environments | OS, dotenv:, env: |
Visible to a shell as $X? | No | Yes |
So a variable like db_host lives in vars and you read it as {{ vars.db_host }}.
You only reach for an environment variable when an external tool (kubectl,
aws, terraform, a CLI that reads $DATABASE_URL) needs it in its process
environment. This page is about that second namespace.
The mindset in one picture
Think of environment variables as a pipeline with a clear IN and OUT. You
declare where values come from (profiled dotenv: files, plus env: overrides);
OrchStep loads them deterministically; an external tool reads them as $X.
The four moving parts: env vars (the values), env profiles (dotenv: files,
one per environment), --env (selects which profile/vars bundle), and the
external tool/script that consumes the result. Keep that flow in mind and the
rest is mechanics.
Best practice: profile it, don't hand-set it
Principle. Avoid hand-setting environment variables on the command line (
DATABASE_URL=… orchstep run …). It's invisible to the next person, easy to get wrong, and never reproducible. Put every required value in a profileddotenv:file and let the workflow load it. The CLI should say what to run, not carry the environment.
The recommended layout uses three kinds of file, loaded in order so the right one wins:
defaults:
environment: dev # the active profile; override with --var environment=prod
dotenv:
- common.env # 1. shared by EVERY profile (always loaded)
- "deploy_{{ vars.environment }}.env" # 2. the selected profile: deploy_dev.env / deploy_prod.env
- "secrets.local.env?" # 3. local-only secrets, git-ignored, OPTIONAL (? = skip if absent)LOG_LEVEL=info
AWS_REGION=us-east-1The
environments/folder is the default home. A baredotenv:filename (common.env,deploy_prod.env) loads fromenvironments/<name>when it exists there — so env config gets a tidy, discoverable home and thedotenv:list stays short. Need a file at the project root instead? Use an explicit path:./common.envor.env(a leading.,./,/, or any/is taken literally).
Why this works:
common.envalways loads — the shared baseline (log level, region, URLs) lives in one place, not copied into every profile.deploy_{{ vars.environment }}.envis chosen byvars.environment, which you set with--var environment=prod(or an environment via--env). One workflow, every target, zeroifbranches.secrets.local.env?holds machine-local secrets that must never be committed; the trailing?makes it optional, so CI (which injects secrets another way) doesn't fail.- In CI, the same workflow still works: don't ship
secrets.local.env; instead the job exports the secrets into the OS env and you mark them<required>so a missing one fails fast.
orchstep run deploy --var environment=prod # loads common.env + deploy_prod.env (+ local secrets if present)env: - set environment variables for a tool
Use env: when you need to hand a value to an external command — set or override
an OS environment variable that a shell step's tool reads as $X. (For loading
many values from a file, prefer dotenv: above; reach for env: to set or override a
specific one at a specific scope.) Declare it at the workflow, task, or step level;
values are templated and exported to the shell, with later scopes winning:
workflow < task < step.
env:
LOG_LEVEL: info
tasks:
deploy:
env:
AWS_REGION: "{{ vars.region }}" # export an OrchStep var for the tool
KUBECONFIG: "{{ env.HOME }}/.kube/config"
steps:
- name: ship
func: shell
env:
DRY_RUN: "true" # step-level wins
do: kubectl apply -f k8s/Defaults need no special syntax - use the template default function:
env:
PORT: "{{ env.PORT | default \"8080\" }}" # 8080 unless PORT is already setdotenv: - load .env / .envrc files
Load environment files explicitly. The parser is chosen by suffix: *.envrc
expects shell export KEY=VALUE; everything else is plain KEY=VALUE.
dotenv:
- .env # base
- "{{ vars.environment }}.env" # dynamic: -> staging.env when environment=staging
- "secrets.local.env?" # trailing ? = optional (skipped if absent)${VAR}/${VAR:-default}expand inside files; single-quoted values are literal.- Paths are template-resolved, so you can pick a file per environment.
- Bare names load from
environments/when present (staging.env->environments/staging.env); a leading././//or any/is literal (.env,./x.envstay at the project root). - Later files win; a missing non-optional file is an error.
- Works at workflow, task, and step level (a step can load its own file).
No auto-loading. OrchStep never sources
.env/.envrcjust because they exist in the directory (unlike direnv) - loading is always explicit, for determinism and security. If your shell already has those vars (e.g. via direnv), OrchStep inherits them anyway.
Requiring a variable: the <required> sentinel
Give an env: entry the value <required> to mean "this must be provided (by the
OS env or dotenv:) - fail fast if missing" instead of setting a value:
env:
DATABASE_URL: <required> # must be provided externally; error if missing
API_TOKEN: <required>
LOG_LEVEL: info # ordinary set$ orchstep run # API_TOKEN not provided
Error: required environment variable(s) not set: API_TOKENThe check runs at whatever scope you declare it - workflow, task, or step - so a task or step can require an input only it needs.
Precedence
When the same variable is set in more than one place, the most specific wins:
OS env < dotenv: < workflow env: < task env: < step env:So a dotenv: value is a baseline that env: overrides, and a step's env: beats
its task's, which beats the workflow's. A quick example:
dotenv: [.env] # .env contains LEVEL=dotenv
env:
LEVEL: workflow
tasks:
main:
env:
LEVEL: task
steps:
- { name: a, func: shell, env: { LEVEL: step }, do: "echo $LEVEL" } # -> step
- { name: b, func: shell, do: "echo $LEVEL" } # -> taska prints step; b (no step env) prints task. Remember: step env: does
not leak to the next step - it's scoped to that step.
Environment is scoped across task calls
The same scoping applies when one task calls another, just like application vars.
A called task's own env:/dotenv: are its defaults; the calling step's
env: overrides them; and they unwind when the task returns - so a
callee's environment never leaks into the caller's later steps or its sibling
tasks:
tasks:
main:
steps:
- { name: call, task: child, env: { FLAG: from_caller } } # caller overrides
- { name: after, func: shell, do: 'echo "FLAG=${FLAG:-unset}"' } # -> unset (unwound)
child:
env: { FLAG: from_child } # the callee's own default
steps:
- { name: in, func: shell, do: 'echo "FLAG=$FLAG"' } # -> from_callerSo a task can ship its own env (batteries-included) and still be retargeted by its caller (inversion of control), with no global leakage.
To see which layer a variable's value actually came from, use
orchstep eval --explain —
it lists effective vars and the OS env your env: sets, each with the layer that won.
Secrets are masked by default
For real credentials, prefer the Secrets namespace (
secrets:+{{ secrets.X }}): the value is fetched from your own tool, kept out of the run history entirely, and scrubbed from output even if a tool prints it. The name-based masking below is a backstop for plainenv:values.
OrchStep classifies variables and masks sensitive ones in templates, logs, and the
context store. Built-in patterns cover *secret*, *token*, *password*, *key*,
and more - no config needed:
env:
PUBLIC_URL: "https://example.com"
API_TOKEN: "abc123"
tasks:
main:
steps:
- { name: show, func: shell, do: 'echo "url={{ env.PUBLIC_URL }} tok={{ env.API_TOKEN }}"' }url=https://example.com tok=***Tune the classification with env_policy: - mark extra variables safe (shown) or
sensitive (masked):
config:
env_policy:
safe: [PUBLIC_URL, "APP_*"] # always shown (exact names or globs)
sensitive: ["INTERNAL_*"] # masked, on top of the built-inssafeentries override the built-in sensitive patterns (e.g. anAPP_KEYyou actually want visible). Exact names and globs both work.sensitiveadds patterns to mask, e.g. a house convention likeINTERNAL_*.- Masking applies everywhere a value could leak: shell output via
{{ env.X }}, logs, and the context store (sensitive vars are excluded from it entirely).
A worked layout
This file uses both halves cleanly: environments: manages the per-target
values (the vars namespace, selected with --env), while dotenv: + env:
provide the OS environment an external tool reads. The steps stay generic.
name: service
# 1. Per-target VALUES — the vars namespace, selected with --env.
# (This is the bundle pattern; steps reference {{ vars.X }} and stay generic.)
environments:
staging:
vars: { target: staging-cluster, version: "2.4.0-rc1" }
production:
vars: { target: prod-cluster, version: "2.4.0" }
# 2. The OS ENVIRONMENT external tools read — shared baseline + optional local secrets.
dotenv:
- common.env # always: LOG_LEVEL, AWS_REGION, …
- "secrets.local.env?" # optional, git-ignored (skipped in CI)
env:
DEPLOY_TOKEN: <required> # must arrive (a dotenv file locally, or CI secrets)
tasks:
deploy:
steps:
- name: ship
func: shell
# {{ vars.* }} come from the selected environment; $DEPLOY_TOKEN from the env.
do: 'echo "deploying {{ vars.version }} to {{ vars.target }}"; deploy --token "$DEPLOY_TOKEN"'orchstep run deploy --env staging # vars.target=staging-cluster, version=2.4.0-rc1
orchstep run deploy --env production # vars.target=prod-cluster, version=2.4.0
--envselects theenvironments:bundle, which populatesvars(see Environments) — it does not setvars.environment. If you also want a per-environment dotenv file (deploy_<env>.env), drive it fromvars.environment(adefaults:value or--var environment=…), as in the recommended layout above.
Common patterns
Secrets from CI - require them, never hard-code:
env:
DATABASE_URL: <required>
DEPLOY_TOKEN: <required>The CI job exports them (e.g. from GitHub Actions secrets); OrchStep just checks they arrived and fails fast if not.
Per-environment config files (the recommended layout):
defaults: { environment: dev }
dotenv:
- common.env # shared baseline (always)
- "deploy_{{ vars.environment }}.env" # deploy_dev.env / deploy_staging.env / deploy_prod.env
- "secrets.local.env?" # optional local secrets, git-ignoredorchstep run --var environment=staging # loads common.env then deploy_staging.envA secret only one step needs (required + scoped):
steps:
- name: publish
env:
NPM_TOKEN: <required>
do: npm publishReuse the .envrc your shell already uses (explicitly, not auto-sourced):
dotenv: [.envrc] # parsed as `export KEY=VALUE`Promote a profile value to the OS env for a tool:
# environments/staging.yml sets region: us-east-1 -> {{ vars.region }}
env:
AWS_REGION: "{{ vars.region }}"When NOT to use env vars
If a value is only consumed by OrchStep (a condition, a template, a task
parameter), keep it in vars - it is clearer, doesn't leak into subprocesses,
and uses one precedence ladder. Reach for env: only at the boundary with an
external tool. For per-environment config bundles, use
Environments (they populate vars).
Where to go next
- Environments - per-target value bundles selected with
--env. - Variables & Outputs - the
varsnamespace. - CLI Reference -
--var,--vars-file,--env.