TL;DR
Мигрировал продакшн базу с Supabase на VPS PostgreSQL прямо на работающем проекте — без остановки, без потери данных. Заодно перенёс авторизацию через strangler-подход и убрал Supabase из SSR read-path. Расскажу три инженерных решения с кодом.
Контекст
Строил маркетплейс недвижимости: карточки объектов, фильтры, личный кабинет, кастомная админка. Стартовал на Supabase — быстро, удобно, auth из коробки.
К середине проекта стало ясно:
RLS политики начинают мешать по мере усложнения логики
Стоимость при масштабировании некомфортная
Контроль над базой — нулевой
152-ФЗ: хранение данных РФ-пользователей на зарубежных серверах — юридический риск
Решение: мигрировать на VPS PostgreSQL. Но без big bang — прод работает, пользователи не должны ничего заметить.
Часть 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 и VPS. Source of truth — 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. Фоновый процесс регулярно сравнивает данные в обеих базах. Расхождения логируются — это даёт уверенность что VPS не отстаёт.
Фаза 3 — Soak period. Несколько дней dual-write + мониторинг. Если расхождений нет — переходим дальше.
Фаза 4 — Переключение source of truth. Читаем из VPS, пишем в обе. Supabase получает данные "на всякий случай".
Фаза 5 — Rollback gate. 48 часов мониторинга. Если метрики ок — убираем дублирующую запись в Supabase. Один флаг возвращает всё назад за 30 секунд.
Результат: нулевой downtime, нулевая потеря данных.
Часть 2: Strangler pattern для Auth — переезд без принудительного разлогина
Почему Auth — самое страшное
Supabase Auth — это не просто таблица users. Это JWT-токены, magic links, RLS, активные сессии. "Выключить и включить свой" — все залогиненные пользователи получат 401 и потребуют перелогина. На проде — неприемлемо.
Решение: auth bridge
Идея strangler: новая система постепенно перехватывает трафик, старая умирает сама.
Шаг 1. Добавляем VPS session layer поверх Supabase Auth. Новые логины проходят через наш session manager, но валидация пароля делегируется в Supabase.
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 разработчик
