Installation
Requirements
- Node.js 24 or newer
- AdonisJS 7
@adonisjs/lucidconfigured against PostgreSQL 14+@adonisjs/redis(cache + counters)@adonisjs/queue(background jobs that provision schemas)@aws-sdk/client-s3(optional, only for S3 backup uploads)jose(optional, only when SSO is enabled)
1. Install and configure
npm install @adonisjs-lasagna/saas-tenancy
node ace configure @adonisjs-lasagna/saas-tenancyThe configure command does three things:
- Registers
MultitenancyProviderinadonisrc.ts. - Publishes
config/multitenancy.tsfrom a typeddefineConfig({...})stub. - Scaffolds
app/models/backoffice/tenant.ts.
By default it also publishes migration stubs for every satellite (audit, feature_flags, webhooks, branding, sso, metrics). You usually want to be selective:
# Only audit logs and webhooks
node ace configure @adonisjs-lasagna/saas-tenancy --with=audit,webhooks
# Interactive (prompts you with a checkbox list)
node ace configure @adonisjs-lasagna/saas-tenancy
# CI-friendly: explicit list, no prompt
node ace configure @adonisjs-lasagna/saas-tenancy --no-interaction --with=audit,branding,feature_flags2. Set up your database connections
Three connection contexts live side by side. Add them to config/database.ts:
// config/database.ts
export default defineConfig({
connections: {
public: {
client: 'pg',
connection: { ...baseConn, searchPath: 'public' },
},
backoffice: {
client: 'pg',
connection: { ...baseConn, searchPath: 'backoffice' },
},
// Tenant connections are created at runtime, no entry needed here.
},
})| Connection | Schema | Purpose |
|---|---|---|
public | public | Shared global data |
backoffice | backoffice | Tenant registry + satellite features |
tenant_<uuid> | tenant_<uuid> | Per-tenant data, created on demand |
Why three connections?
Three lifecycles, three schemas. Data owned by your app (public), data owned by your operators (backoffice), and data owned by individual customers (per-tenant). Mixing them eventually bites; tenant exports leak admin rows, backups balloon, migrations target the wrong schema.
3. Bootstrap the backoffice
node ace backoffice:setupCreates the backoffice schema and runs all satellite-table migrations in one shot. Idempotent; re-run any time.
4. Bind the tenant repository
The package never imports your Tenant model; it asks the IoC container for a TenantRepositoryContract. Wire it once in your app provider:
// providers/app_provider.ts
import { TENANT_REPOSITORY } from '@adonisjs-lasagna/saas-tenancy'
export default class AppProvider {
async boot() {
this.app.container.singleton(TENANT_REPOSITORY, async () => {
const { default: Tenant } = await import('#models/backoffice/tenant')
return {
findById: (id) =>
Tenant.query().whereNull('deleted_at').where('id', id).first(),
findByDomain: (host) =>
Tenant.query().whereNull('deleted_at').where('custom_domain', host).first(),
all: (filters = {}) => {
const q = Tenant.query().whereNull('deleted_at')
if (filters.status) q.where('status', filters.status)
return q
},
}
})
}
}5. Register middleware
// start/kernel.ts
router.use([
() => import('@adonisjs-lasagna/saas-tenancy/middleware')
.then(m => ({ default: m.TenantGuardMiddleware })),
])
server.use([
() => import('@adonisjs-lasagna/saas-tenancy/middleware')
.then(m => ({ default: m.CustomDomainMiddleware })),
])
router.use([
() => import('@adonisjs-lasagna/saas-tenancy/middleware')
.then(m => ({ default: m.RateLimitMiddleware })),
])RateLimitMiddleware is fail-closed by default: if the Redis backend is unreachable, the middleware throws RateLimitUnavailableException (HTTP 503) rather than silently letting traffic through. Opt into the legacy fail-open behaviour on a per-route basis only if you'd rather risk abuse than degraded availability:
// per-route options
.use(middleware.rateLimit({ limit: 100, windowSeconds: 60, failOpen: true }))The middleware also short-circuits when app.inTest === true, so the rest of your integration suite isn't gated on Redis. Tests that target the rate-limit codepath itself must opt in with bypassInTestEnv: true.
6. Create your first tenant
node ace tenant:create "Acme Corp" "admin@acme.example.com"
node ace queue:work # in another terminal — provisions the schemaOnce the InstallTenant job finishes, the row flips to status: 'active' and tenant-scoped routes light up.
Read next
- Tenant identification; pick a resolver strategy.
- Data isolation; choose between schema-per-tenant, database-per-tenant, and row scoping.
- Routing. The
router.tenant(),router.central(), androuter.universal()macros plus custom domain mapping.