CampHost.io is a campground management platform I built from scratch. Campground owners sign up, configure their properties, and manage reservations through a shared platform. Each campground is a tenant — they see only their own data, but they’re all running on the same infrastructure.
Multi-tenancy sounds simple until you actually build it. Here’s what I learned.
Choosing a Tenancy Model
There are three common approaches to multi-tenancy:
- Separate databases — Each tenant gets their own database. Maximum isolation, maximum ops overhead.
- Shared database, separate schemas — One database, but each tenant gets their own schema (PostgreSQL makes this easy). Good isolation, moderate complexity.
- Shared database, shared schema — One database, one schema, tenant ID column on every table. Simplest to manage, requires discipline.
I went with option 3 — shared everything with a tenant_id column. Here’s why:
- Fewer moving parts. One database connection pool, one migration pipeline, one backup strategy.
- Easier analytics. Cross-tenant queries (for platform admin dashboards) are trivial.
- Cost-effective. At the early stage, provisioning separate databases per campground would’ve been wasteful.
The tradeoff is that tenant isolation is enforced at the application layer, not the infrastructure layer. One missed WHERE tenant_id = ? and you’ve got a data leak. That’s where NestJS’s architecture really helps.
Tenant-Scoped Everything
NestJS’s dependency injection system lets you build middleware and guards that extract the tenant context from every request and make it available throughout the request lifecycle:
@Injectable()
export class TenantMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const tenantId = this.extractTenantId(req);
if (!tenantId) throw new UnauthorizedException('Tenant not found');
req['tenantId'] = tenantId;
next();
}
}
Every repository method then includes the tenant filter automatically. I built a base repository class that enforces this:
export abstract class TenantScopedRepository<T> {
async findAll(tenantId: string): Promise<T[]> {
return this.repo.find({ where: { tenantId } });
}
async findOne(tenantId: string, id: string): Promise<T> {
const entity = await this.repo.findOne({ where: { id, tenantId } });
if (!entity) throw new NotFoundException();
return entity;
}
}
This pattern makes it structurally difficult to accidentally query across tenants. The tenant ID isn’t optional — it’s baked into every data access method.
Row-Level Security as a Safety Net
Application-level filtering is necessary but not sufficient. I added PostgreSQL Row-Level Security (RLS) as a defense-in-depth layer:
ALTER TABLE reservations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON reservations
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Before each request, the application sets the app.current_tenant session variable. Even if application code has a bug that skips the tenant filter, the database itself will only return rows belonging to the current tenant.
This is the kind of belt-and-suspenders approach that lets you sleep at night when other people’s business data is on the line.
The Reservation Engine
The core of CampHost is the reservation system. Campsite availability is a surprisingly complex problem:
- Overlapping date ranges — You can’t double-book a site. This requires careful range queries and locking.
- Site types and capacity — Some campgrounds have RV sites, tent sites, cabins — each with different rules.
- Seasonal pricing — Rates change based on time of year, day of week, and holidays.
- Minimum stay requirements — Some sites require 2-night minimums on weekends.
I modeled availability as a series of date range queries with exclusion logic, using PostgreSQL’s daterange type and the && (overlaps) operator:
SELECT * FROM sites
WHERE id NOT IN (
SELECT site_id FROM reservations
WHERE daterange(check_in, check_out) && daterange($1, $2)
AND status != 'cancelled'
)
AND tenant_id = $3;
Scaling Considerations
At the current scale (dozens of campgrounds, thousands of reservations), the shared-schema approach works perfectly. If CampHost grows to thousands of tenants, I’d consider:
- Read replicas for analytics queries that don’t need to touch the primary
- Table partitioning by tenant_id for the largest tables
- Connection pooling with PgBouncer to handle connection limits
The migration to separate schemas or databases would be painful but possible — the tenant-scoped repository pattern means the data access layer is already isolated.
Key Takeaways
Start with shared-schema. The ops simplicity is worth the application-layer discipline, especially early on. You can always shard later.
Enforce tenancy at multiple layers. Application middleware catches most issues. RLS catches the rest. Neither alone is sufficient.
NestJS is excellent for this. The module system, dependency injection, and middleware pipeline make it natural to build cross-cutting concerns like tenant isolation into the framework rather than sprinkling checks throughout business logic.
Think about the query patterns early. Multi-tenant data access has different performance characteristics than single-tenant. Index on (tenant_id, ...) for every table, and be intentional about which queries need to span tenants.