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.
blog/per-tenant-configThe 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:
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=acmeFactor 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
| Approach | Adding tenant #201 | Drift risk |
|---|---|---|
| A task per tenant | copy-paste a whole task | high — 200 copies diverge |
A block per tenant in environments: | a new block each | medium |
Roster + loop: | one line of data | low — one step runs them all |
Roster + loop: + module | one line of data | lowest — 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
- Loops —
items,count, anduntil - Modules — package and reuse the per-tenant logic
- Variables & Outputs — the scoping behind
loop.item
The loop above is runnable in orchstep-demos — add a tenant to the roster and watch the same step provision it.
curl -fsSL https://orchstep.dev/install.sh | sh