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.
blog/dotenv-done-right.env files are everywhere, and so are the three ways they quietly bite you:
- Order. Two files both set
AWS_REGION. Which one wins? If you can't answer instantly, neither can the next person. - 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."
- 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.
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=traceAWS_REGION is us-west-2 — app.env loaded after common.env and won. DATABASE_URL came from the baseline because nothing overrode it. No guessing.
Bare filenames like
common.envload from anenvironments/folder when present, keeping thedotenv:list short. Need a file at the project root? Use an explicit path —./common.envor.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 = skipRequiring 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_URLThe 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 is | config OrchStep consumes | OS env an external tool reads as $X |
| Set by | defaults:, --var, environments | dotenv:, env:, the OS |
Visible to a shell as $X? | no | yes |
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
- Environment Variables —
env:,dotenv:, and name-based masking - Secrets — the masked namespace for real credentials
- Environments — per-target value bundles in the
varsnamespace
The workflow above is runnable in orchstep-demos. The optional overrides.env? is intentionally absent — watch it skip instead of break.
curl -fsSL https://orchstep.dev/install.sh | sh