BLOG/CONFIGURATION MANAGEMENT
THEME
CONFIGURATION MANAGEMENT

dotenv done right

Loading .env files is easy to get subtly wrong: order, optionality, and required values. Here is a deterministic load order, an optional file that won't break CI, and a required-var check that fails fast.

May 16, 2026 OrchStep Team 6 minROLE: AnySCALE: Any
RUNNABLE DEMO
Full source for this post: blog/dotenv-done-right
VIEW SOURCE

.env files are everywhere, and so are the three ways they quietly bite you:

  1. Order. Two files both set AWS_REGION. Which one wins? If you can't answer instantly, neither can the next person.
  2. Optionality. A file with machine-local overrides exists on your laptop and not in CI. Load it unconditionally and CI dies with "file not found."
  3. Required values. A var must be set, but nothing checks — so the failure surfaces three steps later as a cryptic error from some tool, not as "you forgot to set DATABASE_URL."

OrchStep's dotenv: makes all three explicit. Loading is always declared — it never auto-sources a .env just because one is sitting in the directory — and the order you write is the order it loads.

A deterministic load order

The list loads top to bottom, and later files win on any key they share. That is the entire rule.

orchstep.yml
name: dotenv-demo
# dotenv files load top-to-bottom; later files override earlier keys.
# Bare names load from environments/ when present.
dotenv:
  - common.env        # 1. shared baseline, always loaded
  - app.env           # 2. app profile — overrides the baseline where they overlap
  - "overrides.env?"  # 3. optional ad-hoc tweaks; trailing ? = skip if absent
env:
  DATABASE_URL: <required>                       # must arrive via dotenv (or CI); fail fast if missing
  LOG_LEVEL: "{{ env.LOG_LEVEL | default \"info\" }}"
tasks:
  show:
    steps:
      - name: env
        func: shell
        do: 'echo "url=$DATABASE_URL region=$AWS_REGION log=$LOG_LEVEL"'
      - name: scoped
        func: shell
        env:
          LOG_LEVEL: trace                        # step env: wins over everything, for this step only
        do: 'echo "this step runs at log=$LOG_LEVEL"'

Run it and the order is visible in the output:

$ orchstep run show
url=postgres://localhost/app region=us-west-2 log=info
this step runs at log=trace

AWS_REGION is us-west-2app.env loaded after common.env and won. DATABASE_URL came from the baseline because nothing overrode it. No guessing.

Bare filenames like common.env load from an environments/ folder when present, keeping the dotenv: list short. Need a file at the project root? Use an explicit path — ./common.env or .env. A leading ., ./, /, or any / is taken literally.

The optional file: ?

overrides.env? ends in a question mark, which means load it if it exists, skip it silently if it doesn't. That one character is what lets the same workflow run on a laptop that has the file and in CI that doesn't. Without the ?, a missing file is a hard error — which is exactly what you want for files that must be there, and exactly what you don't want for machine-local ones.

dotenv:
  - common.env        # required: missing = error
  - "overrides.env?"  # optional: missing = skip

Requiring a value: the <required> sentinel

Loading a file does not prove the value you need is in it. Give an env: entry the literal value <required> and OrchStep checks that something — a dotenv file, the OS env, CI — actually provided it, and fails fast with a clear message if not:

env:
  DATABASE_URL: <required>     # must be provided externally; error if missing
$ orchstep run                 # DATABASE_URL nowhere to be found
  Error: required environment variable(s) not set: DATABASE_URL

The check runs at whatever scope you declare it — workflow, task, or step — so a single step can demand an input only it needs (NPM_TOKEN on the publish step, say). This is the honest pattern for CI: the job exports the real secret into the OS env, and <required> is your guarantee it arrived before anything touches it.

env: overrides, and step scope

dotenv: sets a baseline; env: overrides specific values for a tool, with the most specific scope winning:

OS env  <  dotenv:  <  workflow env:  <  task env:  <  step env:

In the demo, the scoped step sets LOG_LEVEL: trace in its own env: — so it logs at trace while every other step stays at info. And step env: does not leak to the next step; it is scoped to exactly that step.

vars vs env — don't mix them up

{{ vars.X }}{{ env.X }}
What it isconfig OrchStep consumesOS env an external tool reads as $X
Set bydefaults:, --var, environmentsdotenv:, env:, the OS
Visible to a shell as $X?noyes

Reach for dotenv:/env: only at the boundary with an external tool — the thing that reads $DATABASE_URL. Values OrchStep itself consumes (a condition, a template) belong in vars.

Where to go next

The workflow above is runnable in orchstep-demos. The optional overrides.env? is intentionally absent — watch it skip instead of break.

#DOTENV#ENVIRONMENT-VARIABLES#CONFIGURATION#CI
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — CONFIGURATION MANAGEMENT