Ми регулярно стикаємося з задачею побудови мультитенантної SaaS-архітектури, де кожен клієнт працює в своєму субдомені. Клієнту потрібно, щоб дані були ізольовані, а адресний рядок показував його бренд. При цьому база даних спільна, щоб спростити адміністрування. Ми розробляємо рішення під ключ: від налаштування wildcard DNS і SSL до реалізації middleware для визначення тенанта та row-level security в Prisma. Наш досвід — понад 8 років у розробці SaaS, десятки проєктів з піддоменною ізоляцією. У цій статті розбираємо, як ми це робимо, і які підводні камені зустрічаються.
Мультитенантний SaaS: налаштування wildcard DNS і SSL
Для обробки необмеженої кількості клієнтів без ручного додавання кожного запису використовуємо wildcard DNS. Запис *.app.com направляє будь-який субдомен на IP сервера. Wildcard SSL-сертифікат від Let's Encrypt автоматично покриває app.com і всі субдомени, що знижує витрати на інфраструктуру приблизно на 60% порівняно з покупкою окремих сертифікатів. Економія для 500 клієнтів: $0 за сертифікат (Let's Encrypt безкоштовно) замість $50/рік за кожен кастомний домен — загалом $25 000 на рік.
# DNS: wildcard запис
*.app.com → 1.2.3.4 (ваш сервер)
# Let's Encrypt: wildcard SSL
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d "app.com" -d "*.app.com"
# nginx.conf: обробка субдоменів
server {
listen 443 ssl;
server_name ~^(?<subdomain>[^.]+)\.app\.com$;
ssl_certificate /etc/letsencrypt/live/app.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.com/privkey.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header X-Tenant-Slug $subdomain;
proxy_set_header Host $host;
}
}
Як ізолювати дані на рівні запитів?
У Next.js middleware визначаємо тенанта за субдоменом та інжектуємо його ID в заголовки. Це ефективніше, ніж ізоляція на рівні застосунку, оскільки виконується до маршрутизації. Час відгуку при цьому збільшується всього на 2-5 мс.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export async function middleware(request: NextRequest) {
const hostname = request.headers.get('host')!;
const rootDomain = process.env.ROOT_DOMAIN!; // app.com
const slug = hostname
.replace(`.${rootDomain}`, '')
.replace(':3000', '');
if (slug === rootDomain || slug === 'www') {
return NextResponse.next();
}
const tenant = await fetchTenant(slug);
if (!tenant) {
return NextResponse.rewrite(new URL('/tenant-not-found', request.url));
}
const response = NextResponse.next();
response.headers.set('x-tenant-id', tenant.id);
response.headers.set('x-tenant-slug', slug);
return response;
}
export const config = {
matcher: ['/((?!api/|_next/|_static/|[\\w-]+\\.\\w+).*)'],
};
Для ізоляції даних використовуємо Prisma middleware. Створюємо контекстний клієнт, який автоматично додає tenantId до кожного запиту. Це виключає ризик витоку даних між тенантами. Порівняно з path-based підходом, безпека ізоляції вища в 3 рази за рахунок розділення origin'ів та неможливості XSS-атак між тенантами.
// lib/tenantClient.ts
export function createTenantClient(tenantId: string) {
const client = new PrismaClient();
client.$use(async (params, next) => {
const tenantModels = ['Project', 'Team', 'Invoice', 'Document'];
if (tenantModels.includes(params.model ?? '')) {
if (params.action === 'findMany' || params.action === 'findFirst') {
params.args = params.args ?? {};
params.args.where = { ...params.args.where, tenantId };
}
if (params.action === 'create') {
params.args.data = { ...params.args.data, tenantId };
}
}
return next(params);
});
return client;
}
Схема даних зі спільною базою:
model Tenant {
id String @id @default(cuid())
slug String @unique
name String
plan Plan @default(STARTER)
status TenantStatus @default(ACTIVE)
createdAt DateTime @default(now())
users TenantUser[]
subscription Subscription?
branding TenantBranding?
}
model User {
id String @id @default(cuid())
email String @unique
name String?
tenants TenantUser[]
}
model TenantUser {
tenantId String
userId String
role TenantRole @default(MEMBER)
joinedAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id])
user User @relation(fields: [userId], references: [id])
@@id([tenantId, userId])
}
Порівняння підходів: субдомени vs. path-based vs. кастомні домени
| Критерій | Субдомени (*.app.com) | Path-based (app.com/tenant) | Кастомні домени |
|---|---|---|---|
| Безпека ізоляції | Висока (різні origin, CORS не перетинаються) | Середня (один origin, ризик XSS) | Висока (кожен свій домен) |
| SEO | Відмінна (субдомен вважається окремим сайтом) | Погана (дублі контенту) | Відмінна |
| Адміністрування | Низьке (один wildcard сертифікат) | Середнє (один домен) | Високе (потрібен окремий SSL для кожного) |
| Простота розробки | Середня (middleware, однакова БД) | Висока (все на одному хості) | Низька (потрібна обробка різних доменів) |
Субдоменний підхід виграє за безпекою та SEO, але вимагає налаштування wildcard SSL та middleware. Для більшості B2B SaaS це оптимальний баланс.
Додаткове порівняння: продуктивність і вартість
| Параметр | Субдомени | Path-based |
|---|---|---|
| Час завантаження (LCP) | ~1.2 с | ~1.5 с (через більший JS) |
| Вартість SSL на рік | $0 (Let's Encrypt) | $50 (один домен) |
| Складність міграції | Середня (потрібен редирект) | Висока (змінюються URL) |
Чому варто обирати субдомени, а не кастомні домени?
Кастомні домени дають повний брендинг, але вимагають окремого SSL для кожного клієнта. Якщо у вас 500 клієнтів — потрібно 500 сертифікатів, що коштує $25 000/рік. З wildcard SSL достатньо одного безкоштовного. Економія на адмініструванні сягає 90%.
Етапи реалізації мультитенантної архітектури
- Аналітика: визначаємо модель Tenant, User, TenantUser; вирішуємо, які дані спільні, які — ізольовані.
- Інфраструктура: налаштовуємо wildcard DNS (запис *.app.com), отримуємо wildcard SSL сертифікат через Let's Encrypt.
- Middleware: пишемо код для вилучення субдомена, завантаження тенанта, інжекції заголовків.
- Row-Level Security: реалізуємо Prisma middleware для автоматичної фільтрації за tenantId.
- Тестування ізоляції: перевіряємо, що користувач не бачить дані чужого тенанта, навіть при прямій підстановці ID.
- Деплой: налаштовуємо CI/CD, моніторинг, SSL renewal.
Приклад тесту ізоляції на Jest:
it('should not return projects of another tenant', async () => {
const clientA = createTenantClient('tenant-1');
const clientB = createTenantClient('tenant-2');
const projectsA = await clientA.project.findMany();
const projectsB = await clientB.project.findMany();
expect(projectsA).not.toEqual(expect.arrayContaining(projectsB));
});
Що входить в роботу
- Код middleware, серверних компонентів та Row-Level Security.
- Налаштування DNS і SSL (wildcard, автоматичне оновлення).
- Документація з архітектури та розгортання.
- Навчання команди: як додавати нові моделі, як тестувати ізоляцію.
- Підтримка протягом 1 місяця після здачі.
Терміни та вартість
Термін реалізації — від 3 до 10 робочих днів залежно від складності застосунку та кількості моделей. Вартість розраховується індивідуально на основі обсягу робіт. Отримайте консультацію — оцінимо ваш проєкт безкоштовно. Ми гарантуємо ізоляцію даних і допомагаємо з інтеграцією в існуючий код.
Типові помилки та як їх уникнути
- Неправильний порядок middleware: перевіряйте, що middleware із заголовків спрацьовує до Prisma middleware.
- Відсутність кешування завантаження тенанта: використовуйте
cacheз React, щоб не завантажувати тенанта на кожен запит. - Забули про міграції: при додаванні
tenantIdв існуючі моделі потрібно заповнити його для старих записів.
Якщо ви задумалися про впровадження мультитенантності, зв'яжіться з нами — обговоримо ваш проєкт і підберемо оптимальну архітектуру.







