Onboarding a New Tenant with Infrastructure as Code

This guide, part of Multi-Tenant Architecture Patterns, turns tenant onboarding into a single, repeatable pipeline. Starting from a short tenant definition file, the pipeline creates the CMS space, applies the content model, issues tokens, configures webhooks, attaches the domain and certificate, registers the tenant, seeds content and proves isolation, with an equally automated way back out.

Manual onboarding is where multi-tenant platforms accumulate their worst inconsistencies: a webhook without a secret, a token with management rights where only delivery was needed, a domain registered without its www variant, a tenant missing from the monitoring dashboards. Each step is simple, but twenty simple steps done by hand, weeks apart, by different people, will not be done the same way twice. Codifying them makes every tenant identical in every respect that should be identical.

The onboarding pipelineA tenant definition file in a pull request drives the pipeline, which provisions the CMS space and model, issues tokens and webhooks, attaches the domain and certificate, writes the registry entry, seeds content and finally runs isolation and smoke tests before the tenant goes live.tenant.yamlin a PRCMS space+ modelTokens+ webhooksDomain+ certificateRegistry+ seedIsolation+ smoke tests
The tenant goes live only after the isolation test passes.

The Problem

A platform team onboarded about four new tenants a month, following a 23-step wiki page. An audit of 60 tenants found 11 with webhooks lacking signing secrets, 6 whose delivery tokens also had preview access, 9 whose www domain was not registered and returned the platform’s 404, and 4 missing from the uptime monitoring. Onboarding took between two days and two weeks, mostly waiting for someone with the right access to do the next step. Offboarding had never been done cleanly: tokens for departed tenants were still valid.

How Onboarding as Code Works

The pipeline has one input, a tenant definition, and each step is an idempotent operation that brings the real world in line with it.

YAML
# tenants/acme.yaml
id: acme
tier: space            # shared | space | dedicated
region: eu
domains: [acme.com, www.acme.com]
canonical: www.acme.com
locales: [en, de]
defaultLocale: en
modelVersion: 2026-09
designTokens: acme
features: [search, newsletter]

Idempotent steps. Each step checks the current state and changes only what differs. Running the pipeline twice changes nothing the second time, which makes it safe to rerun after a failure and useful for detecting drift across the whole portfolio.

Secrets never leave the pipeline. Tokens and webhook secrets are created by the pipeline and written directly into the secret manager under names derived from the tenant id. Nobody sees or copies them, so rotating a tenant’s credentials is a pipeline run rather than a coordinated manual change across several systems.

Isolation proven before go-live. The last step publishes a marker entry in the new tenant’s space and checks that it appears on the new tenant’s domain and on no other tenant’s site.

Onboarding time before and afterOnboarding a tenant with the manual wiki process took ten working days on average, mostly waiting between steps; the automated pipeline takes about forty minutes, dominated by certificate issuance and seed import.Manual (median)10 working daysPipeline40 min0 hours20 hours40 hours60 hours80 hourspipeline done
The pipeline removes the waiting, not just the typing.

Implementation

The pipeline is a TypeScript script run by CI when a tenant definition is added or changed. Each step is a function that reads the definition and the current state. The CMS calls below use a generic management client; substitute your platform’s SDK.

TypeScript
// scripts/onboard-tenant.ts
import { readFileSync } from "node:fs";
import { parse } from "yaml";
import { cms } from "./clients/cms";           // management API wrapper
import { secrets } from "./clients/secrets";   // secret manager
import { hosting } from "./clients/hosting";   // domains and certificates
import { registry } from "./clients/registry"; // tenant registry store

interface TenantDef { id: string; tier: string; region: string; domains: string[]; canonical: string; locales: string[]; defaultLocale: string; modelVersion: string; designTokens: string; features: string[] }

async function ensureSpace(t: TenantDef) {
  const space = (await cms.findSpace(`tenant-${t.id}`)) ?? (await cms.createSpace({ name: `tenant-${t.id}`, region: t.region }));
  await cms.ensureLocales(space.id, t.locales, t.defaultLocale);
  await cms.applyModel(space.id, t.modelVersion);        // runs pending migrations up to this version
  return space;
}

async function ensureCredentials(t: TenantDef, spaceId: string) {
  if (!(await secrets.exists(`CMS_TOKEN_${t.id.toUpperCase()}`))) {
    const delivery = await cms.createDeliveryToken(spaceId, { name: "web-delivery", scope: "published" });
    await secrets.put(`CMS_TOKEN_${t.id.toUpperCase()}`, delivery.value);
  }
  if (!(await secrets.exists(`CMS_WEBHOOK_SECRET_${t.id.toUpperCase()}`))) {
    const secret = crypto.randomUUID() + crypto.randomUUID();
    await secrets.put(`CMS_WEBHOOK_SECRET_${t.id.toUpperCase()}`, secret);
    await cms.ensureWebhook(spaceId, { url: `${process.env.WEBHOOK_BASE}/api/cms-webhook?source=${t.region}`, secret });
  }
}

async function ensureDomains(t: TenantDef) {
  for (const domain of t.domains) await hosting.ensureDomain(domain);   // adds domain, requests certificate
  await hosting.waitForCertificates(t.domains, { timeoutMs: 15 * 60_000 });
}

async function ensureRegistry(t: TenantDef, spaceId: string) {
  await registry.put(t.id, {
    hosts: t.domains, canonical: t.canonical, cmsSpace: spaceId, tokenEnv: `CMS_TOKEN_${t.id.toUpperCase()}`,
    webhookSecretEnv: `CMS_WEBHOOK_SECRET_${t.id.toUpperCase()}`, locales: t.locales, defaultLocale: t.defaultLocale,
    designTokens: t.designTokens, features: t.features, tier: t.tier, status: "provisioning",
  });
}

async function proveIsolation(t: TenantDef, spaceId: string) {
  const marker = `isolation-${t.id}-${Date.now()}`;
  await cms.publishMarker(spaceId, marker);
  const own = await (await fetch(`https://${t.canonical}/_health/marker`, { cache: "no-store" })).text();
  if (!own.includes(marker)) throw new Error(`marker not visible on ${t.canonical}`);
  for (const other of await registry.sampleOtherTenants(t.id, 3)) {
    const html = await (await fetch(`https://${other.canonical}/_health/marker`, { cache: "no-store" })).text();
    if (html.includes(marker)) throw new Error(`ISOLATION FAILURE: ${t.id} marker visible on ${other.id}`);
  }
}

const def = parse(readFileSync(process.argv[2], "utf8")) as TenantDef;
const space = await ensureSpace(def);
await ensureCredentials(def, space.id);
await ensureDomains(def);
await ensureRegistry(def, space.id);
await cms.importSeed(space.id, `seeds/${def.modelVersion}.json`, { onlyIfEmpty: true });
await proveIsolation(def, space.id);
await registry.setStatus(def.id, "live");
console.log(`Tenant ${def.id} is live`);

Offboarding with the same pipeline

Offboarding is the same pipeline in reverse, triggered by changing the definition’s status to retired. It exports the tenant’s content if the contract requires it, sets the registry status so the edge returns a 410, revokes tokens and deletes secrets, removes webhooks and domains, purges the tenant’s caches with its {tenant}:all tag, and deletes or archives the space after the contractual retention period. Every step writes an audit record, which becomes the evidence that the tenant’s data and access were removed.

Configuration Reference

Step Idempotency check Failure handling
Space and locales find by name, compare locales Rerun; nothing duplicated.
Content model applied migration version Migrations resume from last applied.
Tokens and webhook secret secret exists in manager Never regenerated if present.
Domains and certificates domain attached, certificate valid Wait with timeout, then fail visibly.
Registry entry compare stored entry Status stays provisioning until tests pass.
Seed content space empty Skipped for existing tenants.
Isolation test always runs Blocks go-live on failure.

Gotchas & Edge Cases

  • DNS is outside your control. Certificates cannot be issued until the tenant’s DNS points to the platform. Split onboarding into provisioning and activation, and let activation wait for DNS.
  • Platform limits. CMS plans limit spaces, tokens and webhooks. Check quotas at the start of the pipeline and fail early with a clear message.
  • Secrets in CI logs. Make sure clients never log token values, and mask secrets in the CI system as a second line of defence.
  • Model drift for older tenants. When the model version advances, existing tenants need the new migrations too. Run the pipeline for all tenants on each model release, relying on idempotency.

Worked Example

The platform team replaced the wiki page with the pipeline and a tenant definition per tenant, then ran it against all 60 existing tenants in dry-run mode, which listed every inconsistency the audit had found and a few more. After fixing them through the pipeline, every tenant had signed webhooks, delivery-only tokens, both domain variants and monitoring. New tenants went live in about 40 minutes after DNS was ready, and the first offboarding through the pipeline revoked three tokens that the manual process would have left active.

Inconsistencies found across 60 tenantsConfiguration problems found by the first dry run of the pipeline across all existing tenants, by kind, all of which were fixed by running the pipeline for real.Unsigned webhooks11 tenants affectedMissing www domain9 tenants affectedOver-scoped tokens6 tenants affectedMissing monitoring4 tenants affectedOther drift7 tenants affected
Every problem the manual audit had found appeared in the dry run, plus seven more.

The team also added the tenant definition files to the quarterly access review. Because each file lists exactly what a tenant should have, the review became a comparison of the pipeline’s dry-run report with the definitions, which takes minutes rather than the day it used to take to click through CMS settings for every space.

Rollout Checklist

  • Write a tenant definition schema and one definition per existing tenant.
  • Implement each onboarding step as an idempotent function.
  • Store generated secrets directly in the secret manager.
  • Gate go-live on an isolation test against other tenants.
  • Run the pipeline in dry-run mode against existing tenants to find drift.
  • Implement offboarding as the reverse pipeline with audit records.

Frequently Asked Questions

Should we use Terraform instead of a script?

Where your CMS and hosting providers have Terraform providers, yes, for the resources they cover. Content model migrations, seed imports and isolation tests usually still need scripts, orchestrated by the same pipeline.

Who approves a new tenant?

The pull request adding the tenant definition is the approval, reviewed by the platform team. That gives every tenant change a history and a reviewer.

How do we onboard tenants on the dedicated tier?

The same pipeline with an extra infrastructure step that provisions the instance first, typically with Terraform, and passes its endpoint to the later steps.

How long should the pipeline be allowed to run?

Most steps finish in seconds; certificate issuance and seed imports take minutes. Set a generous overall timeout, around half an hour, and make each step report progress so a stuck step is obvious in the CI log.

Can the pipeline run on a schedule?

Yes. A nightly dry run across all tenants detects drift, such as a webhook changed by hand, and reports it before it causes problems.