Мы регулярно сталкиваемся с задачей построения мультитенантной SaaS-архитектуры, где каждый клиент работает в своём субдомене. Клиенту нужно, чтобы данные были изолированы, а адресная строка показывала его бренд. При этом база данных общая, чтобы упростить администрирование. Мы разрабатываем решение под ключ: от настройки wildcard DNS и SSL до реализации middleware для определения тенанта и row-level security в Prisma. Наш опыт — более 8 лет в разработке SaaS, десятки проектов с субдоменной изоляцией. В этой статье разбираем, как мы это делаем, и какие подводные камни встречаются.
Как настроить wildcard DNS и SSL?
Для обработки неограниченного числа клиентов без ручного добавления каждой записи используем wildcard DNS. Запись *.app.com направляет любой субдомен на IP сервера. Wildcard SSL-сертификат от Let's Encrypt автоматически покрывает app.com и все субдомены, что снижает затраты на инфраструктуру примерно на 60% по сравнению с покупкой отдельных сертификатов.
# 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 в год | Бесплатно (Let's Encrypt) | Бесплатно (один домен) |
| Сложность миграции | Средняя (нужен редирект) | Высокая (меняются URL) |
Почему стоит выбирать субдомены, а не кастомные домены?
Кастомные домены дают полный брендинг, но требуют отдельного SSL для каждого клиента. Если у вас 500 клиентов — нужно 500 сертификатов. С 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 рабочих дней в зависимости от сложности приложения и количества моделей. Стоимость рассчитывается индивидуально на основе объёма работ. Получите консультацию — оценим ваш проект бесплатно. Мы гарантируем изоляцию данных и помогаем с интеграцией в существующий код.
Типичные ошибки и как их избежать
- Неправильный order middleware: проверяйте, что middleware из заголовков срабатывает до Prisma middleware.
- Отсутствие кэширования загрузки тенанта: используйте
cacheиз React, чтобы не загружать тенанта на каждый запрос. - Забыли про миграции: при добавлении
tenantIdв существующие модели, нужно заполнить его для старых записей.
Если вы задумались о внедрении мультитенантности, свяжитесь с нами — обсудим ваш проект и подберём оптимальную архитектуру.







