אנו נתקלים לעתים קרובות באתגר של בניית ארכיטקטורת SaaS מרובת-דיירים (multi-tenant) שבה כל לקוח פועל בתת-דומיין משלו. הלקוח זקוק לבידוד נתונים ולשורת כתובת ממותגת, תוך שימוש במסד נתונים משותף לניהול פשוט. אנו מספקים פתרון סוהר: מהגדרת DNS עם תווית כללית (wildcard) ו-SSL ועד להטמעת middleware לזיהוי דיירים ואבטחה ברמת שורה ב-Prisma. עם ניסיון של למעלה מ-8 שנים בפיתוח SaaS ועשרות פרויקטים של בידוד תת-דומיינים, הנה איך אנחנו עושים את זה — והמלכודות שכדאי להיזהר מהן.
איך מגדירים DNS עם תווית כללית ו-SSL?
כדי לטפל במספר בלתי מוגבל של לקוחות ללא הוספה ידנית של כל רשומת DNS, אנו משתמשים ב-DNS עם תווית כללית. רשומת *.app.com מפנה כל תת-דומיין לכתובת ה-IP של השרת. תעודת SSL עם תווית כללית מ-Let's Encrypt מכסה אוטומטית את app.com ואת כל תת-הדומיינים, ומפחיתה את עלויות התשתית בכ-60% בהשוואה לרכישת תעודות בודדות (שעולות לפחות 50$ לשנה כל אחת).
# 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;
}
} איך מבודדים נתונים ברמת השאילתה?
ב-middleware של Next.js, אנו מזהים את הדייר לפי תת-הדומיין ומזריקים את ה-ID שלו ל-headers. זה יעיל יותר מבידוד ברמת האפליקציה מכיוון שהוא מבוצע לפני הניתוב. זמן התגובה גדל ב-2-5 אלפיות השנייה בלבד, מהיר פי 3 מחלופות מבוססות נתיב.
// 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+).*)'],
};לבידוד נתונים, אנו משתמשים ב-middleware של Prisma. אנו יוצרים client קונטקסטואלי שמוסיף אוטומטית את ה-tenantId לכל שאילתה. זה מבטל את הסיכון לדליפת נתונים בין דיירים. גישת בידוד תת-הדומיינים מאובטחת פי 3 מבידוד מבוסס נתיב בשל origins נפרדים וחוסר האפשרות להתקפות 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])
} השוואה: תת-דומיינים לעומת מבוסס נתיב לעומת דומיינים מותאמים אישית
| קריטריון | תת-דומיינים (*.app.com) | מבוסס נתיב (app.com/tenant) | דומיינים מותאמים אישית |
|---|---|---|---|
| אבטחת בידוד | גבוהה (origins שונים, ללא חפיפת CORS) | בינונית (origin יחיד, סיכון XSS) | גבוהה (כל אחד עם דומיין משלו) |
| SEO | מצוין (תת-דומיין נחשב לאתר נפרד) | חלש (כפילות תוכן) | מצוין |
| ניהול | נמוך (תעודת תווית כללית אחת) | בינוני (דומיין יחיד) | גבוה (SSL נפרד לכל לקוח) |
| מורכבות פיתוח | בינונית (middleware, מסד נתונים משותף) | גבוהה (הכל על מארח אחד) | נמוכה (צריך לטפל בדומיינים שונים) |
גישת תת-הדומיינים מנצחת באבטחה ו-SEO אך דורשת SSL עם תווית כללית ו-middleware. עבור רוב ה-B2B SaaS, זהו האיזון האופטימלי.
השוואה נוספת: ביצועים ועלות
| פרמטר | תת-דומיינים | מבוסס נתיב |
|---|---|---|
| זמן טעינה (LCP) | ~1.2 שניות | ~1.5 שניות (בגלל JS גדול יותר) |
| עלות SSL לשנה | $0 (Let's Encrypt) | $0 (דומיין יחיד) |
| מורכבות העברה | בינונית (נדרשת הפניה) | גבוהה (כתובות URL משתנות) |
למה לבחור בתת-דומיינים על פני דומיינים מותאמים אישית?
דומיינים מותאמים אישית מספקים מיתוג מלא אך דורשים תעודת SSL נפרדת לכל לקוח. עם 500 לקוחות, תצטרכו 500 תעודות SSL (בעלות של כ-25,000$ לשנה). עם SSL עם תווית כללית, תעודה אחת מספיקה, ומפחיתה את העומס הניהולי בעד 90% וחוסכת אלפי דולרים בשנה.
שלבי יישום
- ניתוח: הגדרת מודלים של Tenant, User, TenantUser; החלטה אילו נתונים משותפים ואילו מבודדים.
- תשתית: הגדרת DNS עם תווית כללית (רשומת *.app.com), קבלת תעודת SSL עם תווית כללית דרך Let's Encrypt (חינם).
- Middleware: כתיבת קוד לחילוץ תת-הדומיין, טעינת נתוני הדייר והזרקת headers.
- אבטחה ברמת שורה: הטמעת middleware של Prisma לסינון אוטומטי לפי tenantId.
- בדיקת בידוד: אימות שמשתמש לא יכול לראות נתונים של דייר אחר, אפילו עם החלפת ID ישירה.
- פריסה: הגדרת CI/CD, ניטור וחידוש SSL אוטומטי.
דוגמה לבדיקת בידוד עם 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, רכיבי שרת ואבטחה ברמת שורה.
- הגדרת DNS ו-SSL (תווית כללית, חידוש אוטומטי).
- תיעוד ארכיטקטורה ופריסה.
- גישה למאגר פרטי עם הקוד המלא.
- הדרכת צוות: הוספת מודלים חדשים, בדיקת בידוד.
- חודש של תמיכה לאחר מסירה (שעתיים בשבוע).
לוח זמנים ועלות
היישום אורך 3 עד 10 ימי עבודה, תלוי במורכבות האפליקציה ובמספר המודלים. התמחור נקבע באופן אישי לפי היקף העבודה, החל מ-5,000$. אנו מבטיחים בידוד נתונים ומסייעים באינטגרציה עם בסיס הקוד הקיים שלכם. קבלו ייעוץ — נעריך את הפרויקט שלכם ונספק הצעת מחיר קבועה.
מלכודות נפוצות וכיצד להימנע מהן
- סדר middleware שגוי: ודאו שה-middleware שמזריק headers רץ לפני ה-middleware של Prisma.
- חוסר במטמון דיירים: השתמשו ב-cache של React כדי להימנע מטעינה חוזרת של הדייר בכל בקשה.
- שכחת מיגרציות: בעת הוספת tenantId למודלים קיימים, מלאו רשומות ישנות.
אם אתם שוקלים SaaS מרובת-דיירים עם בידוד תת-דומיינים, צרו קשר — נדון בפרויקט שלכם ונבחר את הארכיטקטורה האופטימלית.







