BLOG/CONFIGURATION MANAGEMENT
THEME
CONFIGURATION MANAGEMENT

Per-tenant config at scale

When 'an environment' is really 200 tenants, you can't write 200 tasks. Keep the roster as data, loop the provisioning once, give each tenant its own config, and factor the repeated work into a module.

May 12, 2026 OrchStep Team 6 minROLE: Platform EngineerSCALE: Enterprise
RUNNABLE DEMO
Full source for this post: blog/per-tenant-config
VIEW SOURCE

The environment patterns that work for dev/staging/prod fall apart the moment "an environment" means a customer, and you have two hundred of them. You are not going to write two hundred environments: blocks, and you are certainly not going to write two hundred tasks. At this scale, the list of targets stops being structure and becomes data — and the provisioning logic has to run once, parameterized, over that data.

OrchStep handles this with three pieces that compose cleanly: a tenant roster as plain data, a loop: that runs a step per tenant, and a per-iteration config (vars and env) so each tenant gets its own region, tier, and tools. When the per-tenant work grows, you factor it into a module and reuse it everywhere.

The roster is data, not code

Put the tenants in defaults: as a list of objects. Adding a customer is then a one-line edit, reviewed like any config change — not a new task, not a copy-pasted block:

orchstep.yml
name: tenants
# One workflow, many tenants. The tenant roster is data, so adding a customer is a
# one-line edit — not a new task. Each iteration carries its own per-tenant config.
defaults:
  tenants:
    - { id: acme,    region: us-east-1, tier: enterprise }
    - { id: globex,  region: eu-west-1, tier: standard }
    - { id: initech, region: us-west-2, tier: standard }

tasks:
  provision:
    steps:
      - name: each
        func: shell
        loop:
          items: '{{ vars.tenants }}'
        env:
          TENANT_REGION: "{{ loop.item.region }}"   # per-tenant OS env for the tool
        do: 'echo "provisioning {{ loop.item.id }} in $TENANT_REGION (tier={{ loop.item.tier }})"'

  # Provision a single tenant on demand: orchstep run one --var id=acme
  one:
    steps:
      - name: provision
        func: shell
        do: 'echo "provisioning tenant {{ vars.id }}"'

loop: { items: '{{ vars.tenants }}' } runs the step once per entry, exposing the current one as {{ loop.item }}. Because each entry is an object, you reach straight into it — {{ loop.item.id }}, {{ loop.item.region }}, {{ loop.item.tier }}. Run it:

$ orchstep run provision
provisioning acme in us-east-1 (tier=enterprise)
provisioning globex in eu-west-1 (tier=standard)
provisioning initech in us-west-2 (tier=standard)

One step definition, three tenants, three sets of values. Two hundred tenants would be two hundred lines of data and the exact same step.

Per-tenant config, not just per-tenant names

The loop isn't only about iterating names — each iteration carries the tenant's whole configuration. Notice the env: block reads {{ loop.item.region }}, so the tool inside the step sees that tenant's region in $TENANT_REGION. Anything keyed by loop.item is per-tenant: the AWS region, a feature tier that drives an if:, a database shard, a per-customer endpoint. The tenant object is the config bundle.

Need to act on a single tenant — a re-provision after a support ticket — without looping the whole estate? Keep a second task that takes one id:

orchstep run one --var id=acme

Factor the repeated work into a module

When "provision a tenant" grows past an echo — create a namespace, seed a database, register DNS, with retries and error handling — you don't want that logic inlined in a loop where it's hard to test. Move it into a module: a self-contained workflow you import and call as a step, once, with the tenant as input. The loop stays a thin driver; the real work lives in one reusable, independently testable place:

registries:
  platform:
    url: github.com/acme/platform-modules
modules:
  - name: tenant
    source: "@platform/tenant"
    version: "^1.0.0"

tasks:
  provision:
    steps:
      - name: each
        loop: { items: '{{ vars.tenants }}' }
        module: tenant         # the imported module
        task: provision        # its exported task, called per tenant
        with:
          id: "{{ loop.item.id }}"
          region: "{{ loop.item.region }}"
          tier: "{{ loop.item.tier }}"

Now the same @platform/tenant module backs the bulk provision loop, the single-tenant one task, and the onboarding pipeline a new customer triggers — one definition, no drift. See Modules for how imports, registries, and versioning work.

Why this beats the alternatives

ApproachAdding tenant #201Drift risk
A task per tenantcopy-paste a whole taskhigh — 200 copies diverge
A block per tenant in environments:a new block eachmedium
Roster + loop:one line of datalow — one step runs them all
Roster + loop: + moduleone line of datalowest — logic lives once

Where it is not the answer

If tenants differ wildly — different provisioning steps, not just different values — a single loop becomes a thicket of if: branches, and separate workflows (or a module per tenant type) read better. The loop shines when the shape is identical and only the values change, which is the common case for SaaS tenancy.

Where to go next

The loop above is runnable in orchstep-demos — add a tenant to the roster and watch the same step provision it.

#MULTI-TENANT#LOOPS#MODULES#CONFIGURATION
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — CONFIGURATION MANAGEMENT