·4 min read·autor: Sergey Z.

Jak przenieśliśmy tę stronę Next.js z VPS na Cloudflare Workers

Przenieśliśmy wedece.com z VPS-a Hetzner + Coolify na Cloudflare Workers przez OpenNext. Ciekawe uczyniły to dwa ograniczenia: bundle do 3 MiB i brak systemu plików w czasie żądania.

Cloudflare
Next.js
OpenNext
infrastruktura
DevOps

Ta strona kiedyś działała na Hetzner CX32 z Coolify i Dockerem. Działała, ale to była infrastruktura, którą trzeba było niańczyć: rotacja journald, crony docker system prune, health check, rachunek za VPS. Jak na stronę marketingową, która jest niemal w całości statyczna, to sporo ruchomych części. Przenieśliśmy ją na Cloudflare Workers przez @opennextjs/cloudflare. Oto co naprawdę miało znaczenie.

Dlaczego adapter, a nie przepisanie

OpenNext bierze standardowy wynik next build i przepakowuje go w jeden bundle Workera plus statyczny katalog assets/. Nie przepisaliśmy ani linii kodu aplikacji. Podczas cutover trzymaliśmy stary Dockerfile Hetznera przy życiu jako prawdziwy fallback — next.config.ts niósł output: 'standalone' obok wyjścia Workera, więc oba cele buildu współistniały, a rollback był o jedną zmianę DNS. To dokładnie to, czego chcesz w trakcie migracji: fallback realny, nie teoretyczny. Gdy Workers przez kilka tygodni czysto obsłużyły produkcję, usunęliśmy Dockerfile i zdjęliśmy flagę standalone — fallback, którego przestałeś używać, to tylko martwy balast.

Konfiguracja Workera jest mała. nodejs_compat to flaga, która sprawia, że Node-podobny runtime Next.js działa na Workers:

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

Każdy push do main uruchamia Cloudflare Workers Build, który wykonuje opennextjs-cloudflare build i deployuje. Bez runnera GitHub Actions, bez push do registry.

Ograniczenie 1: limit bundle 3 MiB

Workers Free ogranicza skompresowany handler do 3 MiB. Nasz bundle ląduje na ~1.86 MiB gzip — komfortowo, ale tylko dlatego, że najpierw poszliśmy poszukać ciężkiej zależności. Winowajcą na stronie Next.js jest niemal zawsze @vercel/og: konwencja metadanych oparta na plikach (icon.tsx, apple-icon.tsx, opengraph-image.tsx) wciąga cały renderer SVG-do-PNG.

Usunęliśmy te pliki route i zastąpiliśmy je wstępnie wyrenderowanymi statycznymi PNG w public/ (regenerowanymi przez skrypt build). Te same favicony i obraz OG, zero wagi bundle. Jeśli kiedyś trzeba sprawdzić, gdzie jesteś:

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

Ograniczenie 2: brak systemu plików w czasie żądania

Nasze posty bloga to pliki MDX w content/blog/<locale>/. Na VPS loader robił zwykłe readdirSync(process.cwd() + '/content/blog/...') w czasie żądania. Na izolacie Workera to zwraca nic — process.cwd() nie wskazuje na wbudowany katalog content/.

Rozwiązaniem jest przeniesienie odczytu na build time. Skrypt prebuild przechodzi po każdym pliku MDX, parsuje frontmatter i zapisuje src/generated/blog-index.json. Loader w runtime po prostu importuje ten JSON i serwuje wszystko z pamięci. Miły efekt uboczny: ta sama ścieżka kodu wykonywała się identycznie na VPS, więc fallback pozostawał spójny co do bajtu, dopóki jeszcze go trzymaliśmy. Każde nowe źródło treści musi trzymać się tej samej zasady — parsować na buildzie, serwować z pamięci.

Pułapka ze zmiennymi środowiskowymi, która cię ugryzie

Cloudflare rozdziela zmienne build-time i runtime, a mieszkają one w dwóch różnych panelach. Wartości NEXT_PUBLIC_* są wbudowywane w bundle kliencki podczas next build, więc ich miejsce to Settings → Build → Build variables. Sekrety tylko serwerowe (RECAPTCHA_SECRET_KEY, TELEGRAM_BOT_TOKEN) docierają do process.env wewnątrz izolatu w czasie żądania, więc idą do Settings → Variables and Secrets.

Włóż zmienną NEXT_PUBLIC_* do panelu runtime, a odczyta się ona cicho jako undefined w przeglądarce — build już się skończył, zanim izolat ją w ogóle zobaczył. I zmiana zmiennej build nie uruchamia rebuild; pushujesz pusty commit albo klikasz „Re-deploy".

Rezultat

Warm TTFB to 130–180ms, serwowane z najbliższego z ponad 300 POP-ów Cloudflare. Nie ma serwera do łatania, nie ma demona Dockera, nie ma rachunku na darmowym planie. Rate limiter — który kiedyś był in-process Map<ipHash, …>, niemogący nigdy przetrwać granic izolatów — przeniósł się do reguły Cloudflare WAF na edge, gdzie i tak było jego miejsce od początku. Pipeline Hetznera trzymaliśmy o jeden DNS-revert dalej przez okno cutover, a potem wycofaliśmy z użycia, gdy Workers zdobyły zaufanie; rollback dziś to wrangler rollback do poprzedniej wersji Workera. O to właśnie chodzi w ostrożnej migracji — trzymać zabezpieczenie, dopóki nowa platforma się nie sprawdzi, a potem je zdjąć.

Podobało się? Piszemy co kilka tygodni.

Dołącz do newslettera po playbooki studia, narzędzia AI i świeże case studies.