Self-host против Vercel: анатомия standalone, env на билде и за что на самом деле платят платформе
output standalone отдаёт server.js и file-traced срез node_modules, но .next/static, public и sharp вы поставляете сами. NEXT_PUBLIC_-переменные вшиваются в клиентские бандлы на билде, а честная экономика — per-request-тариф против плоской VM с CDN.
В квартал, когда счёт от Vercel переваливает за четыре тысячи долларов — оверейджи за оптимизацию изображений плюс origin-трафик от одного пика в прессе, — платформенная команда получает приказ: переезжаем на наш Kubernetes. Инженер находит в доках output: 'standalone', пишет Dockerfile в пять строк, образ собирается, под зеленеет. А дальше чек-лист запуска падает ровно по списку того, что платформа молча делала за вас. Страница рендерится голым HTML, потому что каждый ассет из /_next/static отвечает 404 — standalone их не копирует. Health check вообще не достукивается до контейнера: server.js слушает localhost, пока не выставлен HOSTNAME=0.0.0.0. Hero-картинки ползут, потому что sharp не пережил сборку рантайм-образа. Через три дня всё работает — а ещё через неделю staging тихо ходит в продакшен-API, потому что NEXT_PUBLIC_API_URL был вшит в клиентский бандл в момент сборки единственного общего образа. В этом списке нет ни одного бага. Это точная опись того, что продавал Vercel.
Что на самом деле выдаёт output standalone
С output: 'standalone' в next.config.js команда next build не просто компилирует: она прогоняет file tracing по серверной сборке — статический анализ каждого require/import, достижимого из серверного кода, — и выдаёт .next/standalone/: минимальный server.js (тонкий HTTP-сервер вокруг обработчика запросов Next) плюс только тот срез node_modules, необходимость которого трейс доказал. Репозиторий с node_modules на 800 МБ обычно трейсится до 50–150 МБ, а multi-stage-образ на node:20-alpine весит порядка 150–250 МБ вместо гигабайта с лишним.
Чего трейс намеренно не включает — всего, что не является серверным кодом. .next/static/ — весь клиентский JS, CSS и ассеты с хешами сборки — билд создаёт, но в standalone не копирует: по замыслу их раздаёт CDN; server.js будет отдавать эти пути, только если папку положить рядом. То же с public/. Оптимизация изображений через next/image требует sharp в рантайм-образе: современный Next подтягивает его как optional-зависимость, но pruned-инсталляции и alpine-сборки регулярно его теряют, а без него /_next/image либо уходит в медленный фолбэк, либо падает. Последняя ловушка невидима до первого контейнерного деплоя: server.js читает PORT и HOSTNAME из окружения, и без HOSTNAME=0.0.0.0 он слушает там, куда не дотянется ни один балансировщик.
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile && pnpm build
# next.config.js: { output: 'standalone' }
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static # в standalone НЕТ — копируй или 404
COPY --from=builder /app/public ./public # в standalone НЕТ — копируй или 404
EXPOSE 3000
CMD ["node", "server.js"]Переменные окружения: вшито на билде против чтения в рантайме
NEXT_PUBLIC_*-переменные не существуют в рантайме браузера — там нечего читать. Бандлер строково заменяет каждое вхождение process.env.NEXT_PUBLIC_X на литерал, существовавший в момент next build, — ровно как define-плагин инлайнит константы. В этом весь механизм классического инцидента: ops меняет NEXT_PUBLIC_API_URL в манифесте деплоя, перезапускает все поды — и ничего не происходит: клиентский бандл по-прежнему содержит старую строку, символ в символ, потому что ребилда не было. Серверный код устроен наоборот: обычный process.env.SECRET в route handler или серверном компоненте читается из процесса вживую на каждом запросе (с одной оговоркой — значения, захваченные при пререндере статической страницы, тоже замораживаются на билде).
Это лоб в лоб сталкивается с дисциплиной build once, promote everywhere, по которой живёт большинство контейнерных команд. Если staging и production делят один образ, каждое NEXT_PUBLIC_-значение в нём принадлежит тому окружению, которое его собрало. Честные варианты: пересобирать на каждое окружение (дёшево с кешем сборки, но образы перестают быть идентичными артефактами); вообще не класть меняющиеся между окружениями значения в NEXT_PUBLIC_ и передавать их с сервера — читать process.env в серверном компоненте и спускать пропсом; или инжектить объект runtime-конфига в HTML из layout. Правило, переживающее аудиты: NEXT_PUBLIC_ — для значений, действительно постоянных на артефакт сборки, а не на окружение.
Ops меняет NEXT_PUBLIC_API_URL в k8s-манифесте и перезапускает все поды. Браузер продолжает ходить по старому URL. Почему, и что реально чинит?
Что Vercel продаёт на самом деле — и где пересекаются линии затрат
Если снять маркетинг, платформа продаёт четыре твёрдые вещи. Глобально реплицированный кеш ISR (Incremental Static Regeneration — пошаговая статическая регенерация) /данных: ревалидация доезжает до каждого региона без вашего общего хранилища. Флот оптимизации изображений: ресайз по требованию на эдже, с кешем, без планирования мощностей под sharp. Anycast-эдж (глобальная сеть точек присутствия, PoP) перед всем этим. И zero-config preview-деплои — полное окружение на каждый pull request; вот это честно сложнее всего воспроизвести самому. Все четыре пункта — это прямой ответ на вопрос «за что я плачу?»; переезжая без этого понимания, вы возмещаете их стоимость инженерными неделями. Контейнер плюс обычный CDN дёшево закрывают остальное: CDN берёт статику и кешируемые страницы, sharp жуёт картинки на вашем CPU, балансировщик — ваш «эдж». Одна вещь молча не переезжает, и она анонсирует следующие два урока: дефолтный кеш ISR — это диск конкретного пода, так что со второй репликой кеш-с-одной-истиной, который давал Vercel, перестаёт существовать.
У экономики есть форма, которую стоит запомнить вместо точных цен — цены дрейфуют. Serverless-биллинг примерно линеен по инвокациям, ГБ-часам, трансформациям изображений и egress; VM — плоская константа с потолком утилизации. Ровный трафик любит плоский тариф; пиковый — метрический. Стабильные 50–100 req/s SSR спокойно живут на паре 4-vCPU машин — порядка 50–100 долларов в месяц плюс egress CDN, — тогда как та же постоянная нагрузка на per-request-биллинге выходит на порядок дороже. Переверните профиль — и ответ перевернётся: базовые 0,5 req/s с редкими 50-кратными пиками от прессы — ровно то, что метрический тариф поглощает без планирования мощностей, а VM пришлось бы держать под пик и простаивать остаток квартала. Точка пересечения не тонкая ни в одну сторону; тонкое — заложить в смету инженерные недели на общий кеш, картиночный конвейер и окружения на каждый PR.
▸Почему это работает
Почему standalone отказывается копировать .next/static, хотя server.js прекрасно умеет раздавать эти файлы? Потому что раздача иммутабельных ассетов с хешами из Node-процесса — фолбэк, а не дизайн. Эти файлы — хрестоматийная нагрузка для CDN: контентно-адресуемые имена, заголовки «кешировать вечно», — и однопоточный Node-сервер, тратящий event loop на файловый IO, — самый дорогой из возможных способов их доставить. Это упущение — мнение: поставьте CDN спереди, направьте на него assetPrefix и оставьте server.js только ту работу, которой действительно нужен сервер.
Дашборд держит ровные 60 req/s SSR круглосуточно, preview-деплои не используются, картинок немного. Какая форма биллинга выигрывает и в чём честная оговорка?
- 01Что лежит внутри .next/standalone, что нужно доложить руками и какие два дефолта ломают первый контейнерный деплой?
- 02Почему смена NEXT_PUBLIC_API_URL в рантайме ничего не даёт и какие три честных выхода?
output standalone превращает next build в шаг упаковки: file tracing обходит серверный граф require и выдаёт server.js плюс только тот срез node_modules, который доказан как необходимый, — 50-150 МБ вместо 800, — но всё клиентское исключено по замыслу. Вы копируете .next/static и public/ в образ (или направляете assetPrefix на CDN), проносите sharp сквозь pruning ради next/image и выставляете HOSTNAME=0.0.0.0, потому что иначе server.js слушает там, куда балансировщик не дотянется. Переменные окружения живут в двух режимах без середины: NEXT_PUBLIC_-значения строково вшиваются в клиентские бандлы на билде — инцидент «поменяли env, ничего не произошло» не про кеширование, а про скомпилированные байты, — тогда как серверный process.env читается вживую на каждом запросе, кроме значений, замороженных пререндером на билде. Этот раскол ломает build-once-promote-everywhere, если меняющиеся значения не приходят с сервера или вы не соглашаетесь на пересборку под окружение. Что платформа продаёт на самом деле: глобально реплицированный кеш ISR, флот оптимизации изображений, anycast-эдж и preview-окружения на каждый PR — последнее воспроизвести труднее всего. Контейнер плюс CDN дёшево закрывают остальное, с одним заряженным исключением, к которому юнит ещё вернётся: дефолтный кеш ISR — диск конкретного пода, и вторая реплика молча разветвляет ваш кеш. По деньгам сопоставляйте форму тарифа с формой трафика: постоянные 50-100 req/s живут на порядок дешевле на плоских VM; почти нулевая база с редкими 50-кратными пиками — это то, ради чего существует per-request-биллинг, — а тонкая строка сметы в обе стороны — инженерные недели на кеш, картинки и preview. Теперь, когда счёт от Vercel вырастет или кто-то предложит «просто написать Dockerfile», вы знаете ровно четыре вопроса, которые нужно закрыть перед переездом.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.