Frog Wizard

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:

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.

#multi-tenancy #terraform