ข้ามไปยังเนื้อหา

Deployment และ Self-Hosting

เรา deploy บน Vercel แบบ zero-config ได้ หรือจะ self-host เป็น Node server หลัง CDN ก็ได้ แต่พอรันมากกว่าหนึ่ง instance เมื่อไร ทั้ง cache แบบ tag-based และ Server Action ต้องมีการประสานกัน (shared cache handler, encryption key คงที่) เพื่อให้ทุก instance ตรงกัน

Vercel ตรวจเจอ Next.js app แล้วจัดการ build, CDN, image optimization และ server runtime ให้โดยไม่ต้อง config self-hosting ให้เราคุมเองทั้งหมด เรา build แล้วรัน next start (Node server ที่อยู่ยาว) เอง โดยปกติวางไว้หลัง CDN หรือ reverse proxy (nginx หรือคล้ายกัน) ที่ cache static asset และกัน request ที่ผิดปกติหรือ rate-limit ไม่ให้ถึง process ของ Next.js โดยตรง

Terminal window
# Self-hosting: build once, then run a long-lived Node server
next build
next start -p 3000

Node.js version ขั้นต่ำที่ Next.js 16 รองรับ: 20.9 ต่ำกว่านี้ไม่รองรับ ไม่ว่าจะ self-host ตรง ๆ หรือเลือก base image สำหรับ container

output: 'standalone' ทำให้ Next.js trace ว่า server ต้องใช้ไฟล์ไหนบ้างแล้ว copy เข้า .next/standalone — image เล็ก ๆ ที่ ship ได้โดยไม่ต้องมี node_modules เต็ม

next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
}
export default nextConfig

Dockerfile สำหรับ production แบ่งเป็นสาม stage — deps ติดตั้ง package ครั้งเดียว, builder compile, runner ship เฉพาะ output ที่ trace มาแล้วโดยรันเป็น non-root user:

# Dockerfile
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]

Build และรันจริงใน probe (docker build แล้ว docker run -p 3412:3000):

#16 4.993 Route (app)
#16 4.993 ┌ ○ /
#16 4.993 ├ ○ /_not-found
#16 4.993 └ ƒ /boom
#20 writing image sha256:ec357758ea09b2b7e8316a98fb4ea19e3e4290da28400f99be1de5ca06bf6196 done
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3412/
200
$ docker exec nextdd-probe-run whoami
nextjs

USER nextjs ไม่ใช่แค่ของประดับ — whoami ข้างใน container ที่รันอยู่จริง ยืนยันว่า process เป็น uid 1001 ไม่ใช่ root สังเกตว่า CMD ของ runner stage คือ node server.js ไม่ใช่ next start หรือ npx next เด็ดขาด: standalone output ship server.js ของตัวเองมาแล้ว ไม่ได้รวม next CLI หรือ node_modules ที่เหลือมาด้วย

server.js ของ standalone เอง default เป็น 0.0.0.0 อยู่แล้วถ้าไม่ set HOSTNAME — grep ไฟล์ที่ generate มายืนยันได้:

const hostname = process.env.HOSTNAME || '0.0.0.0'

การตั้ง ENV HOSTNAME=0.0.0.0 ชัดเจนใน Dockerfile ไม่ได้เปลี่ยน default นั้น — มันแค่กันไม่ให้อะไรอื่นมา override เนื่องจาก HOSTNAME เป็นชื่อทั่ว ๆ ไป orchestration layer หรือ shell บางตัวอาจ set ค่าอื่นที่ไม่ใช่ IP เข้าไป (container ID, pod name) การ pin มันไว้ใน image ตัดปัญหากลุ่มนี้ทิ้งไปแทนที่จะพึ่งค่าที่สืบทอดมาจากที่อื่น

full-route cache กับ ISR เขียนลง cache ของ server ของ Next.js บน instance เดียวที่ self-host filesystem cache แบบ default ก็พอ แต่พอรันหลาย instance แต่ละตัวมี local cache ของตัวเองและมีมุมมองของตัวเองว่า tag ไหนถูก revalidate ไปแล้ว เพราะฉะนั้น revalidateTag บน instance หนึ่งจะทิ้งตัวอื่นให้ค้างจนกว่าจะรู้ตัวเอง

config key สองตัวนี้สับสนกันง่ายเพราะฟังดูเป็นพหูพจน์ของกันและกัน แต่จริง ๆ config คนละระบบ caching เลย:

Keyconfig อะไรเริ่มมีตั้งแต่
cacheHandler (เอกพจน์)full-route / ISR cache แบบเดิม — ตัวที่อยู่หลัง revalidatePath, revalidateTag และ static regenerationก่อน 16, cache override ตัวแรกสำหรับ self-hosting
cacheHandlers (พหูพจน์, เป็น object)backend สำหรับ directive ตระกูล 'use cache' — default สำหรับ 'use cache', remote สำหรับ 'use cache: remote' บวก custom handler ที่ตั้งชื่อเองได้v16.0.0
// next.config.ts — legacy full-route/ISR cache, shared across instances
module.exports = {
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // disable the in-memory cache; use the shared handler
}
// next.config.ts — backends for 'use cache' / 'use cache: remote'
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheHandlers: {
default: require.resolve('./cache-handlers/default-handler.js'),
remote: require.resolve('./cache-handlers/remote-handler.js'),
},
}
export default nextConfig

ถ้าโปรเจกต์ยังใช้ route segment config กับ revalidateTag/revalidatePath (ยังไม่มี 'use cache' เลย) ก็ต้องการแค่ cacheHandler พอเริ่มใช้ 'use cache' backend ของมันจะถูก config แยกผ่าน cacheHandlers — ตั้งอันหนึ่งไม่ได้ config อีกอันให้อัตโนมัติ ไม่ว่าจะแบบไหน tag invalidation ข้าม instance ก็ต้อง implement refreshTags() ของ handler ให้ sync จาก shared storage (Redis หรือคล้ายกัน) ก่อนแต่ละ request ด้วย เพราะ in-memory handler แบบ default track tag แค่ในเครื่องตัวเองเท่านั้น

instrumentation.ts ที่ root ของโปรเจกต์รันครั้งเดียวตอน server instance ใหม่เริ่มทำงาน ก่อนที่มันจะเริ่มรับ traffic และยังดูทุก error ฝั่ง server ผ่าน onRequestError ได้ด้วย

instrumentation.ts
export function register() {
console.log('runtime:', process.env.NEXT_RUNTIME) // 'nodejs' or 'edge'
}
export async function onRequestError(err: unknown, request: any, context: any) {
const message = err instanceof Error ? err.message : String(err)
// ship this to your observability provider instead of console.log
console.log('request error:', message, context.routerKind, context.routeType)
}

พิสูจน์ครบวงจรใน probe: route ที่บังคับ dynamic (export const dynamic = 'force-dynamic') ที่ throw error ถูกยิงด้วย curl แล้วได้ server log จริงแบบนี้ —

✓ Ready in 0ms
INSTRUMENTATION_REGISTER_CALLED nodejs
⨯ Error: probe-boom-error
at <unknown> (.next/server/chunks/ssr/[root-of-the-server]__0v8669b._.js:1:225) {
digest: '3670713050'
}
ON_REQUEST_ERROR_CALLED probe-boom-error App Router render

— ยืนยันว่า register() รันด้วย NEXT_RUNTIME=nodejs ก่อน request แรก และ onRequestError ถูกยิงพร้อม message ของ error ที่ throw กับ context.routeType ('render' สำหรับ Server Component, ยังมี 'route', 'action', หรือ 'proxy' ขึ้นกับว่า error เกิดตรงไหน)

Next.js encrypt closure variable ของ Server Action ก่อนส่งไปหา client โดยใช้ key ที่สร้างตอน build ถ้า build ของแต่ละ instance ได้ key คนละตัว (default) action ที่ encrypt โดย instance A จะ decrypt ด้วย instance B ไม่ได้ แล้ว client จะเจอ error “Failed to find Server Action” ทุกครั้งที่ rolling deploy หรือ load balancer ส่ง request ตามไปยัง instance คนละตัว

Terminal window
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=your-generated-key next build

ตั้งค่านี้ครั้งเดียวแล้วใช้ซ้ำกับทุก build ของทุก instance (base64-encoded AES key ยาว 16, 24 หรือ 32 byte — Next.js สร้างแบบ 32 byte ให้เป็น default ถ้าไม่ตั้งตัวแปรนี้) ส่วนด้าน security ของเรื่องนี้ — encryption ป้องกันอะไรได้และไม่ได้ — เป็นเรื่องของ Server Actions เอง ดูที่ Server Actions Security and Patterns สำหรับอีกครึ่งหนึ่งของภาพนี้ ครึ่งที่บทนี้พูดถึงคือด้าน operations ล้วน ๆ: ถ้าไม่มี key ที่แชร์กัน Server Action ที่ถูกต้องและปลอดภัยอยู่แล้วก็ยังพังได้ทันทีที่รันมากกว่าหนึ่ง instance

นี่คือจุดสุดท้าย เพราะฉะนั้นรวบทั้งคอร์สเข้าด้วยกันก่อน ship

  • เก็บ data fetching ไว้ใน Server Components mark leaf เป็น 'use client' เฉพาะตอนที่ต้อง interactive จริง ๆ
  • เลือก rendering mode ต่อ route อย่างตั้งใจ ทั้ง static, dynamic หรือ ISR แล้ว confirm ว่าแต่ละตัวทำงานตามที่คาดใน production build
  • เข้าใจ caching layer ของตัวเอง (request memoization, Data Cache, full-route cache) แล้ววาง cacheHandler แบบ shared (บวก cacheHandlers ถ้าใช้ 'use cache') ก่อน scale เกินหนึ่ง instance
  • mutate ผ่าน Server Actions แล้วเรียก updateTag / revalidateTag / revalidatePath เพื่อให้ cache ถูกต้องหลัง write — และตั้ง NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ก่อนรันมากกว่าหนึ่ง instance
  • gate route ที่ป้องกันไว้ด้วย optimistic proxy redirect และ Data Access Layer check ควบคู่กัน
  • เก็บ secret แบบไม่มี prefix ไว้ใน .env.local เปิดเผยเฉพาะค่า NEXT_PUBLIC_ และต้องเป็น static literal เท่านั้น
  • ต่อ onRequestError ของ instrumentation.ts เข้ากับ observability provider จริง ก่อนที่ incident แรกจะเกิด ไม่ใช่หลัง

มอง self-hosted instance ทุกตัวเป็นของใช้แล้วทิ้งและสลับกันได้ เหมือนที่ load balancer มองมัน — request ไหนก็ตกไปที่ instance ไหนก็ได้ ณ เวลาไหนก็ได้ รวมถึงตอน rollout กำลังทำอยู่ครึ่ง ๆ สิ่งที่ต้องอยู่รอดได้แม้ request ตกไปคนละ instance กับตอนก่อนหน้า — cache, encryption key ของ Server Action, session — ต้องอยู่ในที่ที่แชร์กันได้ (Redis, env var คงที่, cookie ที่ sign แล้ว) ห้ามอยู่แค่ใน memory ของ instance เดียวหรือ key สุ่มของ build เดียวเด็ดขาด

สิ่งที่ได้ประโยชน์ต้นทุน
VercelCDN, image optimization, runtime แบบ zero-configคุม infrastructure ข้างล่างได้น้อยกว่า
Self-host ด้วย output: 'standalone'image เล็ก คุม infrastructure ได้เต็มที่ต้องดูแล reverse proxy, TLS และ process supervision เอง
Self-host instance เดียวfilesystem cache แบบ default ใช้ได้เลยไม่มี redundancy — process เดียว จุดล้มเหลวจุดเดียว
Self-host หลาย instanceredundancy, scale ตามแนวนอนได้ต้องมี cacheHandler/cacheHandlers แบบ shared และ NEXT_SERVER_ACTIONS_ENCRYPTION_KEY คงที่ ไม่งั้น cache ค้างและ action เริ่มพัง
pin HOSTNAME=0.0.0.0 ชัดเจนตัดความกำกวมว่า default จะ resolve เป็นอะไรมีอีกบรรทัดต้อง maintain ถึงแม้จะตรงกับ default ที่มีอยู่แล้วก็ตาม
  • runner stage แบบ standalone รัน next start หรือ npx next ได้เลย — ไม่ได้ output: 'standalone' trace เฉพาะสิ่งที่ server.js เองต้องการเท่านั้น next CLI และ node_modules ส่วนใหญ่ถูกตัดออกไปตั้งใจ พิสูจน์แล้ว: CMD ["npx", "next", "start"] ใน standalone runner พังทันทีด้วย sh: next: not found
  • cacheHandler กับ cacheHandlers config cache ตัวเดียวกัน — ไม่จริง cacheHandler (เอกพจน์) คือ full-route/ISR cache override แบบเดิม ส่วน cacheHandlers (พหูพจน์, เป็น object) config backend สำหรับ directive ตระกูล 'use cache' เริ่มมีใน v16.0.0 ตั้งตัวหนึ่งไม่มีผลกับอีกตัวเลย
  • HOSTNAME=0.0.0.0 จำเป็นไม่งั้น container จะเข้าไม่ถึง — server.js ของ standalone default เป็น 0.0.0.0 อยู่แล้ว พิสูจน์ได้จากอ่านไฟล์ที่ generate มา การ pin มันชัดเจนก็ยังเป็น practice ที่ดี เพราะมันตัดโอกาสที่อะไรอื่นจะมา override default นั้นแบบเงียบ ๆ แต่มันไม่ได้แก้ช่องโหว่จริงในพฤติกรรม default
  • forbidden()/unauthorized() และ security ของ Server Action เป็นเรื่อง “deployment” ที่ต้องแก้ตรงนี้ — ครึ่งด้าน operations (encryption key ที่แชร์กันเพื่อไม่ให้ deploy หลาย instance พัง) อยู่ในบทนี้ ส่วนครึ่งด้าน security (encryption ป้องกันอะไรได้และไม่ได้) เป็นเรื่องของ authentication กับ Server Actions security
graph TD
  A["CDN / reverse proxy"] --> B["Instance 1 (node server.js)"]
  A --> C["Instance 2 (node server.js)"]
  B --> D["Shared cacheHandler / cacheHandlers (Redis)"]
  C --> D
  D --> E["revalidateTag invalidates all instances"]
  B --> F["Same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY"]
  C --> F
self-hosting หลาย instance ต้องมี cache และ key ที่แชร์กัน

เมื่อ Dockerfile หรือ deployment config ที่ generate มาโผล่ขึ้นมา ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:

  • CMD ของ runner stage รัน next start หรือ npx next แทน node server.js หรือเปล่า? พอใช้ output: 'standalone' แบบนั้นจะพังตอน container เริ่ม — binary next ไม่ได้อยู่ในนั้น
  • Dockerfile จบด้วยการรันเป็น root หรือเปล่า (ไม่มี USER directive)? confirm ด้วย docker exec <container> whoami — ควรได้ชื่อ non-root ไม่ใช่ root
  • config ตั้ง cacheHandler ทั้งที่โปรเจกต์ใช้ 'use cache' จริง ๆ (หรือกลับกัน) หรือเปล่า? มันคนละระบบกัน ตั้งผิดตัวจะทำให้อีกตัวเหลือ default ไว้แบบเงียบ ๆ
  • มี deployment หลาย instance (replica, load balancer, Kubernetes) โดยไม่ pin NEXT_SERVER_ACTIONS_ENCRYPTION_KEY หรือเปล่า? แต่ละ instance จะได้ key สุ่มของตัวเองเป็น default ทำให้ Server Action พังเป็นพัก ๆ ขึ้นกับว่า instance ไหนรับ request ตามมา
  • instrumentation.ts มีอยู่แต่ log ผ่าน console อย่างเดียวใน onRequestError หรือเปล่า? แบบนั้นมองไม่เห็นทันทีที่ stdout ของ container ไม่ถูกเก็บไปที่ไหนเลย ต้อง ship ไปปลายทางจริง
  • base image ใช้ Node.js version ต่ำกว่า 20.9 หรือเปล่า? นั่นต่ำกว่า minimum ที่ Next.js 16 ระบุไว้ ไม่ว่า build วันนี้จะยังผ่านอยู่หรือไม่ก็ตาม

🐛 หา bug ในโค้ดนี้

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
CMD ["npx", "next", "start"]
เฉลย

runner stage copy มาแค่ .next/standalone, .next/static และ public — โดยตั้งใจ นั่นคือทุกอย่างที่ output: 'standalone' trace ว่าจำเป็นสำหรับรัน node server.js มันไม่รวม next CLI หรือ node_modules ที่เหลือมาให้เลย รัน Dockerfile นี้เป๊ะ ๆ จะได้ sh: next: not found ทันทีที่ container เริ่ม เพราะ npx next start ต้องการ package next เต็มตัวที่ไม่เคยถูก copy เข้ามา ทางแก้คือ CMD ["node", "server.js"] — standalone output มี server entry point ของตัวเองมาให้อยู่แล้ว

output: standalone สร้างอะไรออกมา
container นี้จะเกิดอะไรขึ้นตอน start?
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
CMD ["npx", "next", "start"]
ความต่างระหว่าง cacheHandler กับ cacheHandlers คืออะไร
ทำไม standalone server.js ถึงไม่จำเป็นต้อง set HOSTNAME=0.0.0.0 ใน Dockerfile เป๊ะ ๆ
ทำไม deployment หลาย instance ถึงต้อง set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ชัดเจน
Node.js version ขั้นต่ำที่ Next.js 16 รองรับคือเวอร์ชันไหน