TL;DR
Мигрировал продакшн базу с Supabase на PostgreSQL прямо на работающем проекте — без остановки, без потери данных. Заодно перенёс авторизацию через strangler-подход и убрал Supabase из SSR read-path. Расскажу три инженерных решения с кодом.
Контекст
Строил маркетплейс недвижимости: карточки объектов, фильтры, личный кабинет, кастомная админка. Стартовал на Supabase — быстро, удобно, auth из коробки.
К середине проекта стало ясно:
-
RLS политики начинают мешать по мере усложнения логики
-
Стоимость при масштабировании некомфортная
-
Контроль над базой — нулевой
-
152-ФЗ: хранение данных РФ-пользователей на зарубежных серверах — юридический риск
Решение: мигрировать на
Часть 1: Dual-write миграция — переключение source of truth по сущностям
Почему не pg_dump + переключение connection string
Классический подход:
textpg_dump supabase → залить на VPS → поменять DATABASE_URL → молиться
pg_dump supabase → залить на VPS → поменять DATABASE_URL → молиться
Проблемы:
-
Данные между дампом и переключением теряются
-
RLS политики Supabase не переносятся 1-в-1
-
Нет пути назад если что-то пошло не так
Нужен безопасный cutover с rollback на каждом шаге.
Фазы перехода
Фаза 1 — Dual-write. Все записи уходят одновременно в Supabase и
typescriptasync function createListing(data: ListingInput) {
const [supabaseResult, vpsResult] = await Promise.allSettled([
supabaseClient.from('listings').insert(data),
vpsPool.query('INSERT INTO listings ...', [...values])
]);
if (supabaseResult.status !== vpsResult.status) {
await logMigrationDivergence('listings', data.id);
}
return supabaseResult; // source of truth пока Supabase
}
async function createListing(data: ListingInput) { const [supabaseResult, vpsResult] = await Promise.allSettled([ supabaseClient.from('listings').insert(data), vpsPool.query('INSERT INTO listings ...', [...values]) ]); if (supabaseResult.status !== vpsResult.status) { await logMigrationDivergence('listings', data.id); } return supabaseResult; // source of truth пока Supabase }
Фаза 2 — Compare-job. Фоновый процесс регулярно сравнивает данные в обеих базах. Расхождения логируются — это даёт уверенность что
Фаза 3 — Soak period. Несколько дней dual-write + мониторинг. Если расхождений нет — переходим дальше.
Фаза 4 — Переключение source of truth. Читаем из
Фаза 5 — Rollback gate. 48 часов мониторинга. Если метрики ок — убираем дублирующую запись в Supabase. Один флаг возвращает всё назад за 30 секунд.
Результат: нулевой downtime, нулевая потеря данных.
Часть 2: Strangler pattern для Auth — переезд без принудительного разлогина
Почему Auth — самое страшное
Supabase Auth — это не просто таблица users. Это JWT-токены, magic links, RLS, активные сессии. "Выключить и включить свой" — все залогиненные пользователи получат 401 и потребуют перелогина. На проде — неприемлемо.
Решение: auth bridge
Идея strangler: новая система постепенно перехватывает трафик, старая умирает сама.
Шаг 1. Добавляем
typescriptexport async function authenticateUser(email: string, password: string) {
// Валидация пароля через Supabase (временно)
const { data, error } = await supabase.auth.signInWithPassword({
email, password
});
if (error) throw new AuthError('Invalid credentials');
// Создаём свою VPS-сессию
const sessionToken = await createVpsSession({
userId: data.user.id,
email: data.user.email,
});
// Supabase токен не покидает сервер
return { sessionToken, user: mapToVpsUser(data.user) };
}
export async function authenticateUser(email: string, password: string) { // Валидация пароля через Supabase (временно) const { data, error } = await supabase.auth.signInWithPassword({ email, password }); if (error) throw new AuthError('Invalid credentials'); // Создаём свою VPS-сессию const sessionToken = await createVpsSession({ userId: data.user.id, email: data.user.email, }); // Supabase токен не покидает сервер return { sessionToken, user: mapToVpsUser(data.user) }; }
Шаг 2. Middleware переходит на проверку VPS-сессий. Supabase JWT больше не попадает к клиенту.
typescriptexport async function middleware(request: NextRequest) {
const sessionToken = request.cookies.get('vps_session');
const session = await validateVpsSession(sessionToken?.value);
if (!session) {
return NextResponse.redirect('/login');
}
return NextResponse.next();
}
export async function middleware(request: NextRequest) { const sessionToken = request.cookies.get('vps_session'); const session = await validateVpsSession(sessionToken?.value); if (!session) { return NextResponse.redirect('/login'); } return NextResponse.next(); }
Шаг 3. Когда VPS-сессии стабильны — переносим хеши паролей, переключаем валидацию на bcrypt. Supabase Auth выключается.
Результат: ни один пользователь не получил 401. Bridge прожил 3 дня — потом тихо умер.
Часть 3: SSR read-path без Supabase — стабильный рендеринг и SEO
Проблема
Next.js SSR на каждый запрос должен отдать HTML с данными для индексации. Если SSR ходит в Supabase, а тот тормозит — страница не рендерится, поисковик видит пустой HTML.
Задача: убрать Supabase из SSR read-path полностью, не сломав sitemap и метаданные.
Решение: ReadRepository с переключаемым источником
typescriptexport class ReadRepository {
private source: 'supabase' | 'vps';
constructor() {
// Переключается env-флагом без редеплоя
this.source = process.env.READ_SOURCE as 'supabase' | 'vps';
}
async getListing(id: string): Promise<Listing> {
return this.source === 'vps'
? this.vpsGetListing(id)
: this.supabaseGetListing(id);
}
async getListingsForSitemap(): Promise<SitemapEntry[]> {
return this.source === 'vps'
? this.vpsGetSitemapEntries()
: this.supabaseGetSitemapEntries();
}
}
export class ReadRepository { private source: 'supabase' | 'vps'; constructor() { // Переключается env-флагом без редеплоя this.source = process.env.READ_SOURCE as 'supabase' | 'vps'; } async getListing(id: string): Promise<Listing> { return this.source === 'vps' ? this.vpsGetListing(id) : this.supabaseGetListing(id); } async getListingsForSitemap(): Promise<SitemapEntry[]> { return this.source === 'vps' ? this.vpsGetSitemapEntries() : this.supabaseGetSitemapEntries(); } }
Один env-флаг — переключение без редеплоя. Rollback за 30 секунд.
typescriptexport async function generateMetadata({ params }: ListingPageProps) {
const listing = await readRepository.getListing(params.id);
return {
title: listing.title,
description: listing.description,
openGraph: { images: [listing.mainImage] }
};
}
export async function generateMetadata({ params }: ListingPageProps) { const listing = await readRepository.getListing(params.id); return { title: listing.title, description: listing.description, openGraph: { images: [listing.mainImage] } }; }
Результат: SSR latency снизился на 40ms (убрали сетевой хоп до Supabase). Sitemap читает напрямую из PostgreSQL. SEO-индексация без перебоев.
Итоги
|
Метрика |
Результат |
|---|---|
|
Downtime при миграции БД |
0 |
|
Потеря данных |
0 |
|
Принудительные разлогины |
0 |
|
SSR latency |
−40ms |
|
Lighthouse mobile |
94+ |
Ключевой вывод: большие миграции на проде — это не событие, а процесс. Dual-write + compare-job + rollback gate дают уверенность на каждом шаге.
Если интересно — в комментариях отвечу на вопросы по реализации.
Стек: Next.js 15 · TypeScript · PostgreSQL · Tailwind CSS · Vercel
Яков Радченко, Full-Stack разработчик
Автор: yakov_etern8
