MarainMARAIN
← Writing

August 10, 2026

One workflow, many clients

The easiest way to serve a second automation client is to duplicate the first one's workflow and edit the copy. It stops working somewhere around the tenth client, when ten diverging copies of the same logic all need the same fix and fixing one reaches none of the other nine. We built the alternative this week, while we still have nothing to migrate. It is a registry of per-tenant configuration, plus a sub-workflow that every client-facing workflow calls first to find out who it is serving and whether it is allowed to serve them.

The registry

The configuration lives in an n8n Data Table called marain_tenant_registry rather than in a spreadsheet or hardcoded JSON. Each row is one tenant. The columns are a status (ACTIVE or PAUSED), a model tier (public, hybrid, or private) that decides where that tenant's requests get answered, a brand name, a notification channel, and a secret_env_ref that holds the name of an environment variable rather than the credential itself. A tenant that needs its own API key gets that key added to the box's .env file, and the registry points at it by name. The table can be edited without redeploying anything, and it never holds a secret value, so it is safe to screenshot.

The marain_tenant_registry data table in the n8n UI, showing the acme-demo and globex-demo rows with their brand name, status, model tier, and notification channel columns
The marain_tenant_registry data table in the n8n UI, showing the acme-demo and globex-demo rows with their brand name, status, model tier, and notification channel columns

The resolver

tenant-resolve is a sub-workflow rather than a webhook, so it only runs when another workflow calls it. It takes a tenant_id and returns either { ok: true, tenant: {...} } or { ok: false, error, message }, and every caller branches on ok. The error codes are missing_tenant_id, unknown_tenant, tenant_not_active, and invalid_registry_config. None of them is thrown as an exception. A tenant that does not exist, or one that has been paused, is an expected outcome, and treating it as a crash would take down every other tenant's traffic along with it.

The tenant-resolve canvas in the n8n editor: three nodes, When Executed by Another Workflow, Look up tenant, and Resolve tenant (fail closed)
The tenant-resolve canvas in the n8n editor: three nodes, When Executed by Another Workflow, Look up tenant, and Resolve tenant (fail closed)

That is the whole workflow, three nodes. Any future client-facing build calls it first, branches on whether the tenant resolved, and reads modelTier, config, and secretEnvRef from the result to decide what happens next. No tenant-specific logic goes into the new workflow beyond that branch, and the registry row is the only thing that changes between clients.

Two tenants, one code path

To check the pattern against a running system, we built tenant-demo, a webhook workflow that calls tenant-resolve and then answers the caller's message with whichever model the resolved tenant is configured for. Two fixture tenants exercise it.

  • acme-demo is on the public tier, so its requests go to the hosted Anthropic API. A real call against the live workflow took 4.7 seconds.
  • globex-demo is on the private tier, so its requests go to our own Ollama endpoint on the Mac Studio, reached over Tailscale and running qwen2.5:7b-instruct. A real call took 977 milliseconds.

Both tenants exist only as demo fixtures, and no client is running on any of this yet. One Code node makes both model calls, and the only thing that differs between the two paths is data that came out of the registry. A third tenant on either tier needs a new registry row and no new code. We also ran, against the live instance, a paused tenant, an unknown tenant, a payload missing its message field, and requests with a missing and a wrong shared-secret header. Those returned 403, 404, 400 and 401, each with a structured error body.

The empty 200

The unknown-tenant case surfaced a bug worth describing, because it is the failure class that punishes multi-tenant systems hardest. n8n skips any node that receives zero input items, so when the Data table lookup matched no rows, the fail-closed branch in tenant-resolve's Code node never ran. No item reached the workflow's output, and the caller finished with a bare HTTP 200 and an empty body, because the node meant to respond with an error had nothing to respond to.

A rejected tenant that returns an empty 200 looks, in a log, like a served tenant. That is worse than an error page, because nothing flags it for investigation. The fix was to set alwaysOutputData: true on the lookup node, which forces one placeholder item through on a zero-match query, and then filter that placeholder back out in the Code node before deciding whether a real row was found. We re-ran the unknown-tenant case and got a 404 with a structured error body. Any sub-workflow built on looking something up and branching on whether it was found needs the same pairing, or it inherits the same silent hole.

Why we built it now

Nothing here was necessary for the two demo tenants in the registry today. It becomes necessary the moment a second client wants what the first one has, and at that point the alternative is migrating a live workflow out from under a paying customer. Building the chassis before anyone is running on it costs a few days of unglamorous plumbing. Building it afterward costs a migration. If you are deciding whether your own automation should be built this way from the start, that is one of the things the audit is for.