DOC_INDEX
THEME
DOCS/Learn OrchStep/Environment Variables

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 isOrchStep variables (config the workflow consumes)OS environment variables (for external tools)
Set bydefaults:, task/step vars:, --var, environmentsOS, dotenv:, env:
Visible to a shell as $X?NoYes

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.

1 · SELECT--env productionpicksvars.environment = "production"2 · LOAD · dotenv: (later file wins)common.envalways loaded · shareddeploy_<env>.envselected by vars.environmentsecrets.local.env?optional · git-ignored+ env: set / override per scopeworkflow < task < step (for one specific value)3 · RUNorchstep runOS environment · $X4 · USEkubectl · aws · terraform · your scriptreads $DATABASE_URL, $AWS_REGION …
IN: how values enter the env namespace (profiled dotenv + env:) → orchstep run → OUT: the OS environment your tools read.

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 profiled dotenv: 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)
environments/common.env
LOG_LEVEL=info
AWS_REGION=us-east-1

The environments/ folder is the default home. A bare dotenv: filename (common.env, deploy_prod.env) loads from environments/<name> when it exists there — so env config gets a tidy, discoverable home and the dotenv: list stays short. Need a file at the project root instead? Use an explicit path: ./common.env or .env (a leading ., ./, /, or any / is taken literally).

Why this works:

  • common.env always loads — the shared baseline (log level, region, URLs) lives in one place, not copied into every profile.
  • deploy_{{ vars.environment }}.env is chosen by vars.environment, which you set with --var environment=prod (or an environment via --env). One workflow, every target, zero if branches.
  • 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 set

dotenv: - 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.env stay 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/.envrc just 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_TOKEN

The 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" }                         # -> task

a 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_caller

So 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 plain env: 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-ins
  • safe entries override the built-in sensitive patterns (e.g. an APP_KEY you actually want visible). Exact names and globs both work.
  • sensitive adds patterns to mask, e.g. a house convention like INTERNAL_*.
  • 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.

orchstep.yml
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

--env selects the environments: bundle, which populates vars (see Environments) — it does not set vars.environment. If you also want a per-environment dotenv file (deploy_<env>.env), drive it from vars.environment (a defaults: 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-ignored
orchstep run --var environment=staging      # loads common.env then deploy_staging.env

A secret only one step needs (required + scoped):

steps:
  - name: publish
    env:
      NPM_TOKEN: <required>
    do: npm publish

Reuse 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