The web image
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”next.config.ts เพิ่มมาหนึ่งบรรทัด output: 'standalone' infra/web.Dockerfile — multi-stage build สำหรับ apps/web: สเตจ builder ที่ติดตั้ง dependency, รับ NEXT_PUBLIC_API_URL เป็น build argument แล้วรัน next build; และสเตจ runtime ที่ copy มาแค่ .next/standalone, .next/static และ public จากสเตจ builder พ่วงมากับ Dockerfile คือการแก้เล็ก ๆ แต่สำคัญมากใน apps/web/lib/graphql.ts จาก GraphQL client & auth: gqlFetch จะเลือก endpoint ต่างกัน ขึ้นกับว่ากำลังรันบนเซิร์ฟเวอร์หรือใน browser
output: 'standalone' มีอยู่เพราะ Next.js build output แบบ default ไม่ได้ self-contained next build เปล่า ๆ ผลิต .next/ ออกมาพร้อมข้อสมมติว่าต้องมี node_modules วางอยู่ข้าง ๆ ตอน runtime และเป็น node_modules ทั้งก้อน รวมทุก package ที่ next start อาจต้องใช้แบบ transitive ซึ่งโอเคบนเครื่องที่รัน npm install ไปแล้ว แต่ไม่เหมาะกับ Docker image เลย เพราะทุกเมกะไบต์ใน node_modules กลายเป็นเมกะไบต์ในทุก layer ที่ตามมาตลอดไป
output: 'standalone' สั่งให้ Next.js trace runtime dependency graph จริง ๆ โดยเริ่มจาก code path ของ next start ไม่ใช่จาก dependency list เต็มใน package.json แล้ว emit เฉพาะ subset ที่ trace ได้ลงใน .next/standalone พร้อม entry point server.js ที่รันแอปได้เลย ไม่ต้องมีคำสั่ง next start แยก และไม่ต้องมี node_modules เต็มก้อน
ทำไมสเตจ runtime ยังต้อง copy .next/static กับ public เอง server.js ใน .next/standalone ตั้งใจให้เรียบง่ายที่สุด คือ serve หน้าเว็บกับ API route แต่ไม่ serve public/ หรือ .next/static ให้ โดยดีไซน์มาบนสมมติฐานว่า deployment จริงจะมี CDN คั่นหน้า static asset อยู่แล้ว ส่วนคอร์สนี้ไม่มี CDN คั่นหน้าอะไรเลย ทั้งสองโฟลเดอร์จึงต้องถูกวางไว้ข้าง ๆ server.js ตรง ๆ ไม่งั้นทุกรูปใน public/ และทุก CSS/JS chunk ที่ build แล้วใน .next/static จะ 404 ทันทีที่คอนเทนเนอร์เริ่มทำงาน
ทำไม NEXT_PUBLIC_API_URL ต้องเป็น Docker build ARG ไม่ใช่ environment: ตอน runtime GraphQL client & auth เคยอธิบายกลไกนี้ไปแล้ว: Next.js inline ตัวแปรที่มี prefix NEXT_PUBLIC_ เข้าไปใน JavaScript bundle ตอน build time โดยแทนที่ process.env.NEXT_PUBLIC_API_URL ทุกจุดใน source ด้วย string ที่ถืออยู่ระหว่าง next build แบบเดียวกับที่แทนที่ process.env.NEXT_PUBLIC_ANALYTICS_ID ในเอกสารของ Next เอง
พอแทนที่ไปแล้ว ค่านั้นก็ฝังตายอยู่ใน chunk .js ที่อยู่ใน .next/static การไปตั้ง NEXT_PUBLIC_API_URL เป็นตัวแปร environment: ตอน runtime บนคอนเทนเนอร์จึงไม่เปลี่ยนอะไรเลย เพราะไม่มีอะไรใน browser bundle ที่ compile แล้วกลับไปอ่าน process.env ซ้ำ ค่านั้นต้องถูกต้องก่อน next build จะรันในสเตจ builder ซึ่งคือหน้าที่ของ Docker build ARG (ที่แปลงเป็น ENV ให้ RUN npm run build บรรทัดเดียวที่ต้องใช้)
ทำไมเรื่องนี้ถึงต่างไปสำหรับ API_URL_INTERNAL ที่แนะนำด้านล่าง API_URL_INTERNAL ตั้งใจไม่ใส่ prefix NEXT_PUBLIC_ เพราะมีแต่โค้ดฝั่งเซิร์ฟเวอร์ที่อ่านค่านี้ และอ่าน process.env.API_URL_INTERNAL ใหม่ทุกครั้งที่มี request เข้ามา ตามที่ environment จริงของคอนเทนเนอร์ที่กำลังรันบอก ไม่ใช่ค่าที่แช่แข็งไว้ตอน build time นี่คือเหตุผลที่ค่านี้ไปอยู่ใน environment: list ของ Compose ใน The full stack แทนที่จะเป็น build ARG ตรงนี้ ตัวแปรที่มีแต่เซิร์ฟเวอร์อ่านเปลี่ยนได้ตอน deploy time โดยไม่ต้อง rebuild ส่วนตัวแปร NEXT_PUBLIC_ ทำแบบนั้นไม่ได้เลยโดยธรรมชาติ
เส้นแบ่ง server กับ browser ที่โมดูลนี้มีไว้สอนโดยเฉพาะ ข้างใน Compose คอนเทนเนอร์ web กับ api แชร์ network ส่วนตัวที่เรียกหากันด้วยชื่อ service ได้ ชื่อ api resolve ไปที่ address ของคอนเทนเนอร์ API แบบเดียวกับที่ mongo เป็นมาตั้งแต่ Compose skeleton ส่วนbrowserบนเครื่องนักพัฒนาไม่ได้อยู่ใน network นั้นเลย รู้จักแค่ http://localhost:4000 ที่เป็นพอร์ตที่ Compose publish ออกมาให้ host
นั่นคือ address สองแบบที่ใช้แทนกันไม่ได้ สำหรับ API ตัวเดียวกัน และแบบไหนถูกขึ้นอยู่กับว่าโค้ดที่ถามอยู่รันที่ไหน ไม่ใช่ขึ้นกับตัว request เลย gqlFetch ที่เรียกจาก Server Component รันข้างในคอนเทนเนอร์ web จึงต้องใช้ http://api:4000/graphql ส่วน gqlFetch ตัวเดียวกันเป๊ะที่เรียกจากโค้ด 'use client' อย่าง AdminLoginPage รันใน browser ของผู้เข้าชม จึงต้องใช้ http://localhost:4000/graphql ฟังก์ชันเดียว ไฟล์เดียว แต่มีคำตอบที่ถูกสองแบบ ตัดสินด้วย typeof window
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”output: 'standalone' เทียบกับการ copy node_modules เต็มเข้าไปในสเตจ runtime การ copy node_modules ทั้งก้อนเข้าใจง่ายกว่า ไม่มีการ trace ก็ไม่มีโอกาส trace ผิด อะไรที่ npm ci ติดตั้งมาก็คือสิ่งที่ส่งไป แต่ต้นทุนใหญ่และเป็นเรื่องจริง node_modules เต็มของแอป Next.js มักหนักหลายร้อยเมกะไบต์ เกือบทั้งหมดเป็น tooling ที่ใช้แค่ตอน build (eslint, typescript, @types/*) ซึ่ง next start ไม่แตะเลยตอน runtime ส่วนการ trace dependency ของ output: 'standalone' แม่นพอที่ Vercel เขียนเอกสารและแนะนำไว้สำหรับ Docker deployment โดยเฉพาะ แลกมาด้วย next.config เพิ่มบรรทัดเดียวและ COPY instruction เพิ่มอีกสองตัว (static, public) ที่ section Set it up ของบทเรียนนี้พาทำทีละขั้น
build-time ARG สำหรับ NEXT_PUBLIC_API_URL (แบบที่เราใช้) เทียบกับการยอมรับว่า public API URL เปลี่ยนไม่ได้เลยโดยไม่ rebuild ไม่มีทางเลือกที่สามที่ทำให้ NEXT_PUBLIC_API_URL เป็นทั้ง runtime-configurable และ ถูก inline ตามที่ Next.js ต้องการได้พร้อมกัน trade-off นี้ถูกกำหนดตายโดยวิธีทำงานของตัวแปร NEXT_PUBLIC_ ไม่ใช่การตัดสินใจของ Dockerfile นี้ สิ่งที่เป็นทางเลือกจริงคือการพูดผลที่ตามมาออกมาตรง ๆ แทนที่จะซุกไว้: การชี้ image เดียวกันไปที่ API origin อื่น (staging, คนละ domain) หมายถึงต้อง docker build --build-arg NEXT_PUBLIC_API_URL=... ใหม่ ไม่ใช่แค่ docker run -e ใหม่ pipeline production ที่ต้องการ image ก้อนเดียว promote ข้าม environment โดยไม่แก้อะไร จะใช้ same-origin reverse-proxy path แทน (/api/graphql ที่ proxy ฝั่งเซิร์ฟเวอร์) เพื่อให้ URL ที่ผู้ใช้เห็นไม่เปลี่ยน เป็นแพตเทิร์นที่มีจริง แต่อยู่นอกขอบเขตคอร์สนี้ ซึ่ง build image เดียวต่อ Compose stack หนึ่งชุด
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”อัปเดต apps/web/next.config.ts:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = { output: 'standalone',};
export default nextConfig;สร้าง infra/web.Dockerfile:
# ---- builder ----FROM node:20-slim AS builderWORKDIR /app/apps/web
COPY apps/web/package.json apps/web/package-lock.json ./RUN npm ci
COPY apps/web/ ./
# NEXT_PUBLIC_ variables are inlined into the browser bundle at build time —# this has to be set before `next build` runs, not at container start.ARG NEXT_PUBLIC_API_URLENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
# ---- runtime ----FROM node:20-slim AS runtimeWORKDIR /app/apps/web
COPY --from=builder /app/apps/web/.next/standalone ./COPY --from=builder /app/apps/web/.next/static ./.next/staticCOPY --from=builder /app/apps/web/public ./public
ENV PORT=3000ENV HOSTNAME=0.0.0.0
EXPOSE 3000CMD ["node", "server.js"]ARG NEXT_PUBLIC_API_URLแล้วตามด้วยENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URLARGของ Dockerfile ลำพังมองเห็นได้แค่จาก instructionRUNในสเตจเดียวกัน ไม่ใช่จาก process Node.js ที่next buildspawn ขึ้นมา การประกาศซ้ำเป็นENVคือสิ่งที่ทำให้process.env.NEXT_PUBLIC_API_URLresolve เป็นค่าจริงระหว่าง buildENV PORT=3000/HOSTNAME=0.0.0.0server.jsจากoutput: 'standalone'อ่านทั้งสองตัวโดยตรง การใช้0.0.0.0แทนlocalhostหรือ127.0.0.1คือสิ่งที่ทำให้เซิร์ฟเวอร์รับ connection จากนอกคอนเทนเนอร์ได้ ทั้ง port mapping ที่ Compose publish และ browser ที่เรียกlocalhost:3000บน host ต่างเข้ามาในฐานะ connection ภายนอกเท่ากันในมุมของคอนเทนเนอร์- ไม่มี
node_modulesถูก copy เลยในสเตจ runtime จุดประสงค์ทั้งหมดของoutput: 'standalone'คือ.next/standaloneมี subset ที่ trace ไว้ครบตามที่ต้องใช้อยู่แล้ว ต่างจากCOPY --from=builder .../node_modulesที่เขียนไว้ชัด ๆ ใน The API image
อัปเดต apps/web/lib/graphql.ts — endpoint ที่ gqlFetch เรียกจริง ๆ:
export const API_URL = process.env.NEXT_PUBLIC_API_URL!;
// Server Components run inside the `web` container and can't reach the API// at `localhost` — that resolves to the web container itself. They reach it// by container name instead, over the network Compose creates.const SERVER_API_URL = process.env.API_URL_INTERNAL ?? API_URL;แล้วข้างใน gqlFetch เอง แทนที่การเรียก fetch(API_URL, init) แบบ hardcode:
export async function gqlFetch<T>( query: string, variables?: Record<string, unknown>, opts: GqlOptions = {},): Promise<T> { // ...headers and init unchanged from GraphQL client & auth...
const endpoint = typeof window === 'undefined' ? SERVER_API_URL : API_URL; const res = await fetch(endpoint, init); const json = (await res.json()) as GqlResponse<T>;
// ...error handling unchanged...}typeof window === 'undefined'คือเช็คตัวเดียวกับที่lib/auth.tsของ GraphQL client & auth ใช้ตรวจจับการรันบนเซิร์ฟเวอร์อยู่แล้ว —windowมีอยู่ในทุก browser และไม่มีเลยใน process Node.js ไม่ว่าจะเป็น Server Component หรืออะไรก็ตาม ดังนั้นนี่เป็นสัญญาณที่เชื่อถือได้โดยไม่ต้องมี dependency เพิ่มSERVER_API_URLfallback ไปที่API_URLเมื่อAPI_URL_INTERNALไม่ได้ตั้งไว้ — นอกเหนือจาก Docker การรันapps/webแบบ local ด้วยnpm run devเทียบกับapps/apiบน host ทั้งเซิร์ฟเวอร์และ browser เข้าถึง API แบบเดียวกัน (http://localhost:4000/graphql) ดังนั้นการเปลี่ยนนี้ไม่มีผลอะไรเลยจนกว่า The full stack จะตั้งค่าAPI_URL_INTERNALจริง ๆ- ฝั่ง branch ของ browser ไม่ถูกแตะเลย —
AdminLoginPage,PostEditorและ caller'use client'อื่น ๆ ทุกตัวจาก Admin Dashboard ยัง resolve ไปที่API_URLเหมือนเดิมก่อนบทเรียนนี้ทุกประการ
Build image จาก root ของ repo โดยส่ง public API URL เป็น build argument:
cd devblogdocker build -f infra/web.Dockerfile \ --build-arg NEXT_PUBLIC_API_URL=http://localhost:4000/graphql \ -t devblog-web:latest .ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”docker images devblog-web# REPOSITORY TAG IMAGE ID CREATED SIZE# devblog-web latest ... ... seconds ago ~140MBยืนยันว่าการ inline ตอน build time เกิดขึ้นจริง — ค้นหา JavaScript ที่ build แล้วหา URL string ตรง ๆ ไม่ใช่ reference process.env:
docker run --rm devblog-web:latest sh -c "grep -rl 'http://localhost:4000/graphql' .next/static | head -1"การเจอ raw string ข้างใน chunk ที่ compile แล้ว (แทนที่จะไม่เจออะไรเลย หรือเจอ process.env.NEXT_PUBLIC_API_URL ที่ยังไม่ถูกแทนที่) ยืนยันว่าคู่ ARG/ENV ไปถึง next build จริง ๆ ไม่ใช่แค่ environment ตอน runtime ของคอนเทนเนอร์
apps/api ยังไม่ได้รันเป็นคอนเทนเนอร์ ณ จุดนี้ในคอร์ส — The full stack คือที่ที่ web มีอะไรให้เรียกจริง ๆ ตอนนี้ ให้ยืนยันแค่ว่าเซิร์ฟเวอร์เองบูตได้สะอาด:
docker run --rm -p 3000:3000 devblog-web:latest▲ Next.js ...- Local: http://localhost:3000✓ Ready in ...msการเปิด http://localhost:3000 จะ render โครงของหน้า — การดึงข้อมูลกับ API ที่ไม่มีอยู่จริงจะล้มเหลว ที่เป็นเรื่องปกติและโอเคที่นี่ ประเด็นของ section Verify นี้คือการยืนยันว่า server.js เริ่มทำงานและ serve public/.next/static ได้ถูกต้อง ไม่ใช่หน้าที่ทำงานได้สมบูรณ์
output: 'standalone' ทำให้ Next.js trace runtime dependency graph จริงของตัวเองลงใน .next/standalone แทนที่จะสมมติว่ามี node_modules เต็มวางอยู่ข้าง ๆ สเตจ runtime ของ infra/web.Dockerfile จึง copy output ที่ trace แล้ว บวก .next/static และ public (สองอันหลังตั้งใจไม่รวมใน server.js เพราะออกแบบมาบนสมมติฐานว่ามี CDN serve ให้ ซึ่ง stack ของคอร์สนี้ไม่มี)
NEXT_PUBLIC_API_URL ต้องเข้ามาในฐานะ Docker build ARG แล้วแปลงเป็น ENV ก่อน next build จะรัน เพราะ Next.js inline ตัวแปร prefix NEXT_PUBLIC_ เข้า browser bundle ตอน build time entry ใน environment: ตอน runtime จึงมาถึงช้าเกินกว่าจะเปลี่ยนอะไรที่ compile ไปแล้ว ส่วน gqlFetch ใน lib/graphql.ts ตอนนี้เลือกระหว่าง API address ที่ถูกต้องสองแบบ — API_URL_INTERNAL (ชื่อบน container network) เมื่ออยู่บนเซิร์ฟเวอร์ และ NEXT_PUBLIC_API_URL (localhost) เมื่ออยู่ใน browser — โดยดูแค่ typeof window แล้ว fallback กลับไปเป็นพฤติกรรมเดิมทุกประการเมื่อรันนอก Docker
ถัดไป: The full stack →