The Gold Standard for Scale
For large-scale SaaS platforms, the industry best practice is to generate a separate directory — and therefore a completely separate Terraform root module with its own state file — for every tenant. This is what "cell-based architecture" means in IaC terms.
The directory structure looks like this:
infrastructure/
├── modules/
│ └── tenant_stack/ # Shared module: VPC, DB, IAM, etc.
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── tenants/
├── client-acme/
│ ├── terragrunt.hcl # Or main.tf with backend config
│ └── tenant.auto.tfvars
├── client-beta/
│ ├── terragrunt.hcl
│ └── tenant.auto.tfvars
└── client-gamma/
├── terragrunt.hcl
└── tenant.auto.tfvars
Each tenant directory is a thin wrapper that calls the shared module with tenant-specific variables. Using Terragrunt (a thin wrapper around Terraform), a tenant's config is just a handful of lines: a pointer to the shared module, and the tenant's own inputs (name, size, region, instance class). Terragrunt automatically configures a unique remote state backend for each directory, so client-acme and client-beta have completely separate state files with zero shared configuration.
The Automated Onboarding Workflow
The real power of this pattern is that tenant directories can be generated programmatically. When a new customer signs up, your backend API can trigger a script that copies a template directory into a new tenant-named directory, writes that tenant's specific variables into it, and commits and pushes the result to Git — which is all it takes to trigger the pipeline, since the CI/CD system (GitHub Actions, GitLab CI, Spacelift, Atlantis) detects which tenant directories changed in the commit and runs terraform apply only for those directories. This gives you:
- Targeted deployments: Updating the shared module only applies to tenants you explicitly re-run.
- Parallel execution: CI can apply 10 tenant directories simultaneously.
- Full audit trail: Every tenant's infrastructure change is a Git commit.
Comparing All Three Strategies
| Dimension | Module per Tenant | Workspace per Tenant | Directory per Tenant |
|---|---|---|---|
| State isolation | ❌ Shared | ✅ Separate | ✅ Separate |
| Blast radius | All tenants | One tenant | One tenant |
| Plan speed at scale | ❌ Very slow | ✅ Fast | ✅ Fast |
| Onboarding automation | ✅ Easy (add map entry) | ⚠️ Script required | ⚠️ Script required |
| Drift visibility | ✅ Single plan shows all | ❌ No native view | ✅ Git history per tenant |
| Security boundary | ❌ Shared credentials | ❌ Shared credentials | ✅ Can use separate IAM roles |
| Recommended scale | < 20 tenants | 20–200 tenants | 200+ tenants |
A common interview question is: "What's the difference between Terraform workspaces and separate state files?" The key distinction is that workspaces are a convenience feature for state isolation within a single backend configuration — they share the same credentials, the same backend, and the same code. Separate directories with separate backend configurations can use different AWS accounts, different IAM roles, and different backend buckets entirely. For true security isolation (e.g., SOC 2 compliance requiring tenant data to be in separate AWS accounts), workspaces are insufficient; you need separate root modules with separate provider configurations.
Key Point: The directory-per-tenant pattern is the most operationally complex to set up but the most scalable and secure to operate. The upfront investment in automation (the onboarding script, the CI/CD pipeline) pays dividends at scale. Teams that start with the module-per-tenant approach almost always migrate to directory-per-tenant as they grow — doing that migration with 500 tenants in production is painful. If you anticipate significant growth, start with directory-per-tenant from day one.