js

How to Build a Secure Multi-Tenant SaaS Backend with Hapi.js and Knex.js

Learn how to implement schema-based multi-tenancy in your SaaS app using Hapi.js, Knex.js, and PostgreSQL. Step-by-step guide included.

How to Build a Secure Multi-Tenant SaaS Backend with Hapi.js and Knex.js

I’ve been thinking about this a lot lately. Building software that serves multiple customers from a single codebase isn’t just a technical challenge—it’s a business necessity. Every time I start a new SaaS project, the question of how to keep each customer’s data separate and secure comes up. It’s the foundation everything else is built on. Today, I want to walk you through how I approach this using Hapi.js and Knex.js. This isn’t theory; it’s the exact system I’ve used in production.

Let’s start with the basics. Multi-tenancy means one application serves many customers. Think of it like an apartment building. Everyone lives in the same structure, but each apartment is private. The key is making sure no one can see into their neighbor’s space. How do we build digital walls that are both strong and efficient?

There are three main ways to do this. The first puts everyone in the same room with name tags. All data goes into the same database tables, and a tenant_id column tells us who owns what. It’s simple but risky. One wrong query could show someone else’s information.

The second method gives each tenant their own schema within the same database. It’s like giving each apartment its own locked storage unit in the building’s basement. This is the approach I prefer for most projects. It offers a good balance of security and manageability.

The third method gives each tenant a completely separate database. Maximum security, but also maximum complexity and cost. For most growing applications, the schema-per-tenant approach hits the sweet spot.

Ready to build something real? Let’s set up our project. You’ll need Node.js, PostgreSQL, and TypeScript. Here’s how I start every project:

mkdir my-saas-backend
cd my-saas-backend
npm init -y
npm install @hapi/hapi knex pg
npm install -D typescript @types/node

The structure matters. I organize code by what it does, not what it is. Here’s my typical folder layout:

src/
├── middleware/    # Handles tenant detection
├── services/      # Business logic
├── routes/        # API endpoints
├── plugins/       # Hapi.js plugins
└── utils/         # Shared helpers

Configuration comes next. I use environment variables for anything that changes between setups. Create a .env file:

DATABASE_URL=postgresql://user:pass@localhost:5432/saas_db
JWT_SECRET=your-secret-here
SERVER_PORT=3000

Now, let’s build our server. Hapi.js makes this straightforward with its plugin system. I create a plugin specifically for handling tenant isolation. This plugin runs on every request, figuring out which tenant is making the call.

// src/plugins/tenant-isolation.ts
import { Plugin } from '@hapi/hapi';

const tenantIsolationPlugin: Plugin<{}> = {
    name: 'tenant-isolation',
    register: async (server) => {
        server.ext('onPreHandler', (request, h) => {
            // Extract tenant from subdomain, header, or JWT
            const tenantId = request.headers['x-tenant-id'] 
                           || request.query.tenant;
            
            if (!tenantId) {
                throw new Error('Tenant identification required');
            }
            
            // Store tenant context for this request
            request.app.tenantId = tenantId;
            return h.continue;
        });
    }
};

This simple middleware runs before any route handler. It looks for tenant information in headers or query parameters. In a real application, you’d want to validate this against a database of active tenants. What happens if someone provides a fake tenant ID?

Database setup is where Knex.js shines. We need to configure it to work with multiple schemas. Here’s my database configuration:

// src/config/database.ts
import knex from 'knex';

export const getKnexConfig = (tenantSchema?: string) => {
    return {
        client: 'pg',
        connection: process.env.DATABASE_URL,
        searchPath: tenantSchema ? [tenantSchema, 'public'] : ['public'],
        pool: {
            min: 2,
            max: 10
        }
    };
};

// Create a tenant-specific connection
export const getTenantConnection = (tenantId: string) => {
    return knex(getKnexConfig(`tenant_${tenantId}`));
};

Notice the searchPath setting? This is PostgreSQL’s way of saying “look in this schema first.” When we set it to tenant_123, all our queries automatically use that tenant’s tables. No need to prefix every table name with the schema.

But where do these schemas come from? We need to create them. Here’s a service that handles schema management:

// src/services/tenant-schema.service.ts
import { knex } from '../config/database';

export class TenantSchemaService {
    async createTenantSchema(tenantId: string): Promise<void> {
        const schemaName = `tenant_${tenantId}`;
        
        // Create the schema
        await knex.raw(`CREATE SCHEMA IF NOT EXISTS ??`, [schemaName]);
        
        // Run migrations in the new schema
        await this.runMigrationsForSchema(schemaName);
    }
    
    private async runMigrationsForSchema(schemaName: string): Promise<void> {
        // Get base migration SQL
        const migrations = await this.getTemplateMigrations();
        
        // Execute each migration in the tenant's schema
        const tenantKnex = knex({ 
            ...knex.client.config, 
            searchPath: [schemaName] 
        });
        
        for (const migration of migrations) {
            await tenantKnex.raw(migration.sql);
        }
    }
}

When a new customer signs up, this service creates their private schema and sets up all the necessary tables. Each tenant gets their own complete set of tables, identical in structure but separate in data.

Query building needs special attention. We must never forget which tenant we’re working with. I wrap Knex.js queries to automatically add tenant filtering:

// src/services/query-builder.service.ts
export class QueryBuilderService {
    constructor(private tenantId: string) {}
    
    async getUsers(filters: any = {}) {
        const db = getTenantConnection(this.tenantId);
        
        let query = db('users').select('*');
        
        // Always filter by tenant (extra safety)
        query = query.where('tenant_id', this.tenantId);
        
        // Apply any additional filters
        if (filters.active !== undefined) {
            query = query.where('is_active', filters.active);
        }
        
        return query;
    }
    
    async createUser(userData: any) {
        const db = getTenantConnection(this.tenantId);
        
        // Automatically add tenant_id to all inserts
        const dataWithTenant = {
            ...userData,
            tenant_id: this.tenantId,
            created_at: new Date()
        };
        
        return db('users').insert(dataWithTenant).returning('*');
    }
}

This might seem like overkill since we’re using separate schemas, but I add the tenant_id filter anyway. It’s a defensive practice. If somehow a query runs against the wrong schema, the tenant_id check provides another layer of protection.

Migrations require special handling. We need to run them across all tenant schemas. Here’s how I manage this:

// src/services/migration.service.ts
export class MigrationService {
    async runForAllTenants(): Promise<void> {
        // Get all active tenants
        const tenants = await this.getAllTenants();
        
        for (const tenant of tenants) {
            console.log(`Migrating tenant: ${tenant.id}`);
            await this.runForTenant(tenant.id);
        }
    }
    
    async runForTenant(tenantId: string): Promise<void> {
        const db = getTenantConnection(tenantId);
        
        // Check if migrations table exists
        const hasMigrationsTable = await db.schema.hasTable('migrations');
        
        if (!hasMigrationsTable) {
            await this.createMigrationsTable(db);
        }
        
        // Run pending migrations
        await this.executePendingMigrations(db);
    }
}

Performance is critical. Connection pooling becomes even more important with multiple schemas. Each tenant connection should reuse connections when possible. How do we prevent one tenant’s slow query from affecting others?

I use a connection pool manager that limits connections per tenant:

// src/utils/connection-pool.ts
class ConnectionPoolManager {
    private pools: Map<string, any> = new Map();
    private maxConnectionsPerTenant = 5;
    
    getConnection(tenantId: string) {
        if (!this.pools.has(tenantId)) {
            this.pools.set(tenantId, this.createPool(tenantId));
        }
        
        return this.pools.get(tenantId);
    }
    
    private createPool(tenantId: string) {
        return knex({
            client: 'pg',
            connection: process.env.DATABASE_URL,
            searchPath: [`tenant_${tenantId}`],
            pool: {
                min: 1,
                max: this.maxConnectionsPerTenant
            }
        });
    }
}

This ensures no single tenant can consume all database connections. Each gets their own pool with limits.

Testing requires careful planning. We need to test that data doesn’t leak between tenants. Here’s a test I always include:

// tests/tenant-isolation.test.ts
describe('Tenant Isolation', () => {
    it('should not leak data between tenants', async () => {
        // Create test data for tenant A
        await createUser('tenant_a', { name: 'Alice' });
        
        // Try to access from tenant B
        const tenantBUsers = await getUsers('tenant_b');
        
        // Should be empty
        expect(tenantBUsers).toHaveLength(0);
    });
    
    it('should handle concurrent tenant requests', async () => {
        // Simulate multiple tenants accessing simultaneously
        const promises = [
            getUsers('tenant_1'),
            getUsers('tenant_2'),
            getUsers('tenant_3')
        ];
        
        const results = await Promise.all(promises);
        
        // Each should only see their own data
        results.forEach((result, index) => {
            expect(result.tenantId).toBe(`tenant_${index + 1}`);
        });
    });
});

Monitoring is different in a multi-tenant system. We need to track performance per tenant. I add tenant context to all logs:

// src/middleware/logging.ts
server.ext('onPreResponse', (request, h) => {
    const response = request.response;
    
    console.log({
        timestamp: new Date().toISOString(),
        tenantId: request.app.tenantId,
        method: request.method,
        path: request.path,
        statusCode: response instanceof Error ? 500 : response.statusCode,
        duration: Date.now() - request.info.received
    });
    
    return h.continue;
});

This lets me see if one tenant is experiencing more errors or slower responses than others. It’s essential for maintaining quality of service across all customers.

Security considerations are paramount. Beyond data isolation, we need proper authentication and authorization. Each tenant might have different permission schemes. I implement a role-based system that’s tenant-aware:

// src/middleware/auth.ts
const validateToken = async (request, token) => {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    
    // Check if token matches requested tenant
    if (decoded.tenantId !== request.app.tenantId) {
        throw new Error('Token tenant mismatch');
    }
    
    // Check if user has access to this tenant
    const hasAccess = await checkTenantAccess(
        decoded.userId, 
        request.app.tenantId
    );
    
    if (!hasAccess) {
        throw new Error('No access to this tenant');
    }
    
    return { isValid: true, credentials: decoded };
};

Common mistakes? I’ve made most of them. Forgetting to set the search path. Not handling schema creation failures. Assuming all tenants want the same database indexes. The key is to expect things to fail and handle them gracefully.

One particular issue: what happens when a tenant needs a custom field? With schema-per-tenant, you can actually give them custom tables or columns. But this requires careful management. I usually offer a limited set of customizable fields through a metadata table rather than altering schema structure.

Scaling brings new challenges. When you have hundreds of tenants, running migrations across all of them takes time. I use a queue system to spread the load:

// src/services/migration-queue.ts
export class MigrationQueue {
    async scheduleMigration(migrationName: string) {
        // Get all tenants
        const tenants = await this.getAllTenants();
        
        // Add to queue with rate limiting
        for (const tenant of tenants) {
            await this.queue.add('run-migration', {
                tenantId: tenant.id,
                migrationName
            }, {
                // Process 10 tenants at a time
                jobId: `${migrationName}-${tenant.id}`,
                priority: 1
            });
        }
    }
}

This approach lets me control how many migrations run simultaneously, preventing database overload.

What about tenant onboarding? The process needs to be smooth. When someone signs up, we create their schema, run base migrations, and seed initial data—all within a transaction so if anything fails, nothing is partially created.

Backup and recovery require special thought too. We need to backup each tenant’s schema individually. In a disaster recovery scenario, we might need to restore just one tenant without affecting others.

The beauty of this architecture is its flexibility. Need to move a high-volume tenant to their own database? Since they’re already in a separate schema, the move is relatively straightforward. Their data is already isolated.

I’ve used this pattern for applications serving from 10 to 10,000 tenants. The principles remain the same, though the implementation details evolve. Start with clean separation, add monitoring early, and always think about how each decision affects tenant isolation.

Remember, the goal isn’t just technical perfection. It’s building a system that instills confidence in your customers. They need to trust that their data is safe and separate. Every design decision should reinforce that trust.

What questions do you have about implementing this in your own projects? Have you encountered specific challenges with multi-tenant architectures? I’d love to hear about your experiences in the comments below. If you found this guide helpful, please share it with other developers who might be facing similar challenges.


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: multi-tenancy,saas backend,hapi.js,knex.js,postgresql



Similar Posts
Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Operations

Learn to integrate Next.js with Prisma ORM for type-safe, scalable web apps. Build seamless database interactions with modern tools. Start coding today!

Blog Image
How to Build a Distributed Rate Limiting System with Redis and Node.js Cluster

Build a distributed rate limiting system using Redis and Node.js cluster. Learn token bucket algorithms, handle failover, and scale across processes with monitoring.

Blog Image
Why Fastify and Joi Make the Perfect Pair for Bulletproof API Validation

Learn how combining Fastify with Joi creates powerful, intuitive validation for cleaner, safer, and more maintainable APIs.

Blog Image
Build High-Performance Node.js File Upload System with Multer Sharp AWS S3 Integration

Master Node.js file uploads with Multer, Sharp & AWS S3. Build secure, scalable systems with image processing, validation & performance optimization.

Blog Image
Complete Guide: Integrating Next.js with Prisma ORM for Type-Safe Database-Driven Applications

Learn how to integrate Next.js with Prisma ORM for type-safe, database-driven web apps. Build scalable applications with seamless data flow and TypeScript support.

Blog Image
Build Production-Ready GraphQL APIs with Apollo Server, TypeScript, and Prisma: Complete Guide

Learn to build production-ready GraphQL APIs with Apollo Server, TypeScript & Prisma. Complete guide with auth, performance optimization & deployment.