Tenants are data, not code
When you have to scale to hundreds of tenants, managing each one of them in code stops being tractable.
A tenant is data. The infrastructure is code. Keep them in different places and onboarding a client becomes writing one JSON file, no new Terraform, no new pipeline job, no new repo.
It works by separating what from how, with a third layer binding them:
- What the env should be: per-client config, JSON in S3, edited often.
- How each piece is built: Terraform modules and Ansible roles, semver-tagged in git, immutable once tagged.
- Which how each what needs: an app-version manifest pinning the exact module versions a given app build requires.
A brand-new env is six fields:
{
"schemaVersion": "1.0",
"clientId": "acme",
"environment": "prod",
"region": "us-east-1",
"appVersion": "2025.4.1",
"network": { "vpcCidr": "10.20.0.0/16", "azCount": 2 }
}
appVersion is the join key. Its manifest declares the infra that build needs:
{
"appVersion": "2025.4.1",
"moduleVersions": {
"terraform": { "network": "3.2.0", "compute": "5.1.2", "data": "2.0.4" },
"ansible": { "app-config": "1.8.0", "app-deploy": "4.0.0" }
}
}
So "newer versions need different infra" stops being a fork. The infra requirement travels with the app version; a client adopts a new shape by bumping one field.
The pipeline resolves the config against the manifest and the pinned modules, assembles an ephemeral workspace, and applies it. Nothing per-client is committed to git.
Overrides, feature-flag flips, and hotfixes are optional blocks on the same file, merged by precedence. The base case stays six fields.