·4 min read·автор: Сергій З.

Як ми перенесли цей Next.js-сайт із VPS на Cloudflare Workers

Ми перенесли wedece.com із Hetzner + Coolify VPS на Cloudflare Workers через OpenNext. Цікавим це зробили два обмеження: бандл до 3 MiB і відсутність файлової системи під час запиту.

Cloudflare
Next.js
OpenNext
інфраструктура
DevOps

Раніше цей сайт крутився на Hetzner CX32 з Coolify і Docker. Він працював, але це була інфраструктура, яку доводилося няньчити: ротація journald, крони docker system prune, health check, рахунок за VPS. Для маркетингового сайту, який майже повністю статичний, це забагато рухомих частин. Ми перенесли його на Cloudflare Workers через @opennextjs/cloudflare. Ось що справді мало значення.

Чому адаптер, а не переписування

OpenNext бере стандартний вивід next build і переупаковує його в один Worker-бандл плюс статичну директорію assets/. Ми не переписали жодного рядка застосункового коду. На час cutover ми тримали старий Hetzner Dockerfile живим як справжній fallback — next.config.ts ніс output: 'standalone' поряд із Worker-виводом, тож обидва build-таргети співіснували, а відкат був за одну зміну DNS. Це саме те, що потрібно посеред міграції: fallback реальний, а не теоретичний. Коли Workers кілька тижнів чисто відпрацювали продакшн, ми видалили Dockerfile і прибрали прапорець standalone — fallback, який ви перестали використовувати, це просто мертвий вантаж.

Конфіг Worker невеликий. nodejs_compat — це прапорець, який змушує Node-подібний рантайм Next.js працювати на Workers:

{
  "name": "wedece-main",
  "main": ".open-next/worker.js",
  "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
  "assets": { "directory": ".open-next/assets", "binding": "ASSETS" }
}

Кожен push у main запускає Cloudflare Workers Build, який виконує opennextjs-cloudflare build і деплоїть. Без GitHub Actions runner, без push у registry.

Обмеження 1: ліміт бандла 3 MiB

Workers Free обмежує стиснутий handler до 3 MiB. Наш бандл лягає в ~1.86 MiB gzip — комфортно, але лише тому, що ми спершу пішли шукати важку залежність. Винуватець на Next.js-сайті майже завжди @vercel/og: конвенція метаданих на основі файлів (icon.tsx, apple-icon.tsx, opengraph-image.tsx) тягне за собою цілий рендерер SVG-в-PNG.

Ми видалили ці route-файли й замінили їх заздалегідь відрендереними статичними PNG у public/ (перегенеровуються build-скриптом). Ті самі favicon і OG-зображення, без ваги бандла. Якщо колись треба перевірити, де ви:

npm run cf:build
gzip -c .open-next/server-functions/default/handler.mjs | wc -c

Обмеження 2: немає файлової системи під час запиту

Наші пости блогу — це MDX-файли в content/blog/<locale>/. На VPS завантажувач робив звичайний readdirSync(process.cwd() + '/content/blog/...') під час запиту. На Worker-ізоляті це повертає нічого — process.cwd() не вказує на вбудовану директорію content/.

Виправлення — перенести читання на build time. Скрипт prebuild обходить кожен MDX-файл, парсить frontmatter і пише src/generated/blog-index.json. Рантайм-завантажувач просто імпортує цей JSON і віддає все з пам'яті. Приємний побічний ефект: той самий код-шлях виконувався ідентично на VPS, тож fallback лишався консистентним байт у байт, поки ми його ще тримали. Будь-яке нове джерело контенту має дотримуватися того ж правила — парсити на білді, віддавати з пам'яті.

Пастка зі змінними оточення, яка вас вкусить

Cloudflare розділяє змінні build-time і runtime, і живуть вони у двох різних панелях. Значення NEXT_PUBLIC_* вбудовуються в клієнтський бандл під час next build, тож їм місце в Settings → Build → Build variables. Серверні секрети (RECAPTCHA_SECRET_KEY, TELEGRAM_BOT_TOKEN) досягають process.env усередині ізоляту під час запиту, тож вони йдуть у Settings → Variables and Secrets.

Покладіть змінну NEXT_PUBLIC_* у runtime-панель — і вона тихо читатиметься як undefined у браузері: білд уже завершився до того, як ізолят її взагалі побачив. І зміна build-змінної не запускає rebuild; ви пушите порожній commit або тиснете «Re-deploy».

Результат

Warm TTFB — 130–180ms, віддається з найближчого з 300+ Cloudflare POP. Немає сервера, який треба патчити, немає Docker-демона, немає рахунку на безкоштовному тарифі. Rate limiter — який раніше був in-process Map<ipHash, …>, що ніколи не міг пережити межі ізолятів — переїхав у Cloudflare WAF-правило на edge, де йому й місце було від початку. Hetzner-пайплайн ми тримали за один DNS-revert протягом вікна cutover, а тоді вивели з експлуатації, коли Workers заслужили довіру; відкат сьогодні — це wrangler rollback до попередньої версії Worker. У цьому й весь сенс акуратної міграції — тримати підстраховку, поки нова платформа не покаже себе, а тоді її прибрати.

Сподобалось? Пишемо щокілька тижнів.

Підпишіться, щоб отримувати плейбуки студії, AI-інструменти та свіжі кейси.

    Як ми перенесли цей Next.js-сайт із VPS на Cloudflare Workers · Wedece