BLOG/ENTERPRISE AUTOMATION
THEME
ENTERPRISE AUTOMATION

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.

Jul 7, 2026 OrchStep Team 9 minROLE: Platform EngineerSCALE: Scale-up / Enterprise

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

  1. As a library / function. The caller injects the data (with: / env:); the module is reusable logic with fallback defaults:. Great for a notify or slack-message module where the caller obviously owns the inputs.

  2. As a configured instance. The module is a self-contained unit that ships its OWN config: a dotenv:, an env:, and environments/<profile> vars. The caller doesn't inject anything — it selects a profile and the module loads its own config. This is what a real billing-service or search-service deploy 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 own environments/<name> (plus its dotenv: and env:), 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: / import config:). The OS environment is always inherited.

They're independent, so they give four modes:

Two knobs, four modes: Inherited, Configured, Sealed, Pinned, laid out as a 2x2 grid by instance_profile and isolate

  • 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.

One release fanning out to three service modules — billing, search, checkout — each loading its own prod profile, dotenv, and env, scoped with no cross-bleed

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's region: eu and STRIPE_KEY do not bleed into the search call that runs right after it. Each frame loads and unloads its own world.
  • No leak into the caller. After ship calls billing, the release workflow's later steps don't suddenly have STRIPE_KEY in 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-off with: { 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_profile picks the profile; isolate decides whether the caller is excluded. Two orthogonal knobs, four clear modes.
  • "@caller" turns one --env into 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.

#MODULES#CONFIGURATION-MANAGEMENT#MONOREPO#PLATFORM-ENGINEERING#ENVIRONMENTS
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — ENTERPRISE AUTOMATION