Run a module as a configured instance, not just a function
A shared deploy module either forces every caller to re-pass all the config, or its environment leaks across calls. instance_profile + isolate let a module load its OWN per-environment config — so one release fans out to every service's own prod profile, scoped and clean.
You wrote a deploy module once and shared it across the org. It was supposed to
save everyone time. Instead, every consumer workflow now carries a wall of config
to feed it — registry URLs, replica counts, region, resource limits, ingress hosts —
duplicated per environment, drifting silently. The module that was meant to hold
the deploy knowledge made every caller responsible for it anyway.
That's the tension this post is about: a module can be called two ways, and until recently OrchStep only let you express one of them cleanly.
Two ways to call a module
-
As a library / function. The caller injects the data (
with:/env:); the module is reusable logic with fallbackdefaults:. Great for anotifyorslack-messagemodule where the caller obviously owns the inputs. -
As a configured instance. The module is a self-contained unit that ships its OWN config: a
dotenv:, anenv:, andenvironments/<profile>vars. The caller doesn't inject anything — it selects a profile and the module loads its own config. This is what a realbilling-serviceorsearch-servicedeploy module wants to be.
The first was always supported. The second wasn't: a called module loaded only its
defaults: — its environments/, dotenv:, and env: were ignored entirely, so a
"self-contained" module couldn't actually be self-contained. You worked around it by
hoisting all that config up into every caller. That's the wall of duplication.
The fix: two call-step knobs
Two new keys on a module-call step select how the module gets its context:
instance_profile: <name>— run the module as a configured instance using its ownenvironments/<name>(plus itsdotenv:andenv:), resolved relative to the module. Use"@caller"to follow the caller's active environment name.isolate: true— clean-room: exclude the caller's context and overrides (with:/env:/ importconfig:). The OS environment is always inherited.
They're independent, so they give four modes:
- Inherited (default, unchanged) — caller drives; the module's own env/dotenv are not loaded. Your existing module calls behave exactly as before.
- Configured — module loads profile
p, and the caller can still override. - Sealed — no profile, but the module runs on its own baseline, isolated.
- Pinned — module's exact profile
p, clean-room. The reproducible deploy unit.
The industry case: a monorepo release that doesn't lie
Picture a platform team with a dozen services in one repo, each packaged as a module with its own per-environment config:
modules/
billing/
orchstep.yml # defaults:, dotenv: [billing.env], env: { BILLING_TIER }
billing.env # STRIPE_KEY=...
environments/
prod.yml # region: eu, replicas: 6
staging.yml
search/ ... # its own shards, ELASTIC_URL, environments/
checkout/ ... # its own canary %, PSP_TOKEN, environments/The release workflow is the boring part it should be:
name: release
modules:
- { name: billing, source: ./modules/billing }
- { name: search, source: ./modules/search }
- { name: checkout, source: ./modules/checkout }
tasks:
ship:
steps:
- { module: billing, task: deploy, instance_profile: "@caller" }
- { module: search, task: deploy, instance_profile: "@caller" }
- { module: checkout, task: deploy, instance_profile: "@caller" }Now orchstep run ship --env prod fans out: each module loads its own prod
profile — billing gets region: eu, replicas: 6 and STRIPE_KEY, search gets its
shard count and ELASTIC_URL, checkout gets its canary percentage and PSP_TOKEN.
No config in the caller. One --env drives every service to its matching profile.
Switch the whole fleet to staging by changing one flag: --env staging. The release
workflow never mentions a region, a replica count, or a secret. Each module owns its
own truth, and the release just says go.
Why "scoped" is the word that matters
Everything a configured instance loads — its profile vars, its dotenv, its env — is scoped to that call. It unwinds when the module returns. Two consequences that turn a neat idea into a safe one:
- No cross-bleed in a fan-out.
billing'sregion: euandSTRIPE_KEYdo not bleed into thesearchcall that runs right after it. Each frame loads and unloads its own world. - No leak into the caller. After
shipcallsbilling, the release workflow's later steps don't suddenly haveSTRIPE_KEYin their environment.
This is the same scoping model OrchStep uses for application variables, now applied to a module's whole bundled context. (More on the environment side of that in your callee's env shouldn't haunt your caller.)
When to reach for each mode
- Configured (
instance_profile: prod) — the common case. Self-contained module, but you might still pass a one-offwith: { canary: 25 }to nudge a single run. - Pinned (
instance_profile: prod, isolate: true) — when the deploy must be exactly the module's prod profile and nothing the caller does can perturb it. Audit-friendly: the inputs are fully determined by the module + profile name. - Sealed (
isolate: true, no profile) — a self-contained tool that should run on its own baseline regardless of where it's called from (a linter, a backup job). - Inherited (neither) — keep using it for genuine library modules where the caller owns the data.
A named instance_profile the module doesn't actually declare fails fast, with a
message that tells you where it looked — so a typo'd profile is a load error, not a
silent half-configured deploy.
What changed under the hood
For the curious: a module's bundled dotenv: and env: are now loaded for the first
time (they were previously ignored), resolved relative to the module's directory, and
applied through the same push/pop scope that makes the unwind guarantee hold. The
default Inherited path is byte-for-byte unchanged, so adopting this is purely
additive — add instance_profile: to a call when you want instance behavior, and not
before.
Takeaways
- A shared module shouldn't force its config onto every caller. Let it own its per-environment config and select a profile per call.
instance_profilepicks the profile;isolatedecides whether the caller is excluded. Two orthogonal knobs, four clear modes."@caller"turns one--envinto a fleet-wide fan-out where each service loads its matching profile.- It's all scoped — no cross-service bleed, no leak into the caller.
Start here: Using Modules → context modes and Creating Modules → shipping a configured instance.
curl -fsSL https://orchstep.dev/install.sh | sh