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.
ββββββββββββββββ ββββββββββββββββ ββββββββββββββββ
β tenant configβ β app-version β β tagged β
β (S3) β β manifest β β modules (git)β
β clientId,env β β pinned moduleβ β terraform + β
β appVersion β β versions β β ansible β
ββββββββ¬ββββββββ ββββββββ¬ββββββββ ββββββββ¬ββββββββ
β appVersion β resolve β
β join key β refs β
βββββββββββββββββββββΊββββββββββββββββββββββΊβ
β β
βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββ
β ephemeral workspace β
βββββββββββββββββββββββββ¬ββββββββββββββββββββββββ
βΌ
terraform apply
pipeline {
agent any
parameters {
string(name: 'CLIENT', defaultValue: 'acme')
string(name: 'ENV', defaultValue: 'prod')
}
stages {
stage('Fetch config') {
steps {
sh "aws s3 cp s3://tenants/tenants/${params.CLIENT}/${params.ENV}.json terraform.tfvars.json"
}
}
stage('Resolve modules') {
steps {
sh '''
APP=$(jq -r .appVersion terraform.tfvars.json)
aws s3 cp s3://tenants/manifests/$APP.json - | \
jq '{module: (.moduleVersions.terraform | to_entries | map({
(.key): {source: "git::ssh://git@host/tf-modules/\\(.key)?ref=v\\(.value)"}
}) | add)}' > main.tf.json
'''
}
}
stage('Apply') {
steps {
sh "terraform init -backend-config=key=${params.CLIENT}/${params.ENV}.tfstate"
sh "terraform apply -auto-approve -var-file=terraform.tfvars.json"
}
}
}
}
Three stages: pull the config, resolve the manifest into module blocks, apply. Same pipeline for every tenant; the data is what differs.
Overrides, feature-flag flips, and hotfixes are optional blocks on the same file, merged by precedence.