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 разработчик