Lỗi phổ biến nhất khi mới viết Dockerfile: build luôn trong 1 stage duy nhất — cài devDependencies, chạy build, rồi COPY toàn bộ thư mục (kể cả node_modules, source TypeScript, test file) vào image production. Kết quả là image nặng gấp nhiều lần mức cần thiết, kéo theo build lâu hơn, push/pull lâu hơn, và bề mặt tấn công lớn hơn (nhiều package hơn = nhiều lỗ hổng tiềm ẩn hơn).
Vấn đề của single-stage Dockerfile
# Cách làm sai — 1 stage duy nhất
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/main"]Dockerfile trên có 3 vấn đề: (1) npm install cài cả devDependencies (TypeScript, ESLint, test runner...) — không cần thiết lúc chạy production; (2) base image node:20 đầy đủ (khoảng 1GB) thay vì bản alpine gọn hơn nhiều; (3) source code TypeScript gốc, test file, .git vẫn nằm trong image cuối dù chỉ dist/ mới thực sự cần chạy.
Multi-stage build — tách giai đoạn build và giai đoạn chạy
Ý tưởng: dùng nhiều FROM trong cùng 1 Dockerfile, mỗi FROM là một stage riêng biệt. Stage đầu (builder) có đầy đủ công cụ để build, stage cuối (production) chỉ COPY đúng file kết quả đã build từ stage trước — không mang theo bất kỳ thứ gì khác.
# Stage 1 — builder: có đủ công cụ để build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2 — production: chỉ mang code build sẵn + dependency cần thiết
FROM node:20-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
EXPOSE 8080
CMD ["node", "dist/main"]Điểm mấu chốt là dòng COPY --from=builder — lấy đúng thư mục dist/ đã build sẵn từ stage builder, bỏ qua toàn bộ source TypeScript, node_modules đầy đủ (dev+prod), test file. Stage builder sau khi build xong coi như bị vứt bỏ hoàn toàn, không đóng góp gì vào image cuối ngoài file được COPY --from chỉ định.
Với Next.js — dùng output: 'standalone'
Next.js có chế độ standalone output, tự động phân tích và chỉ copy đúng node_modules cần thiết (loại bỏ cả những package không dùng tới trong runtime), giảm image size đáng kể so với việc tự COPY toàn bộ node_modules production.
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.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
COPY --from=builder /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]Lưu ý: standalone output chỉ copy dependency được Next.js phát hiện là thực sự dùng tới lúc runtime — nếu code của bạn require một package theo cách động (require(variable)) mà Next không phân tích tĩnh được, package đó có thể bị thiếu trong bundle standalone và lỗi khi chạy.
Tận dụng layer cache đúng cách
Docker cache từng layer theo thứ tự lệnh trong Dockerfile — nếu 1 layer không đổi so với lần build trước, Docker dùng lại cache thay vì chạy lại. Vì vậy nên COPY package*.json và chạy npm ci TRƯỚC khi COPY toàn bộ source code: sửa code không làm thay đổi package.json, nên layer cài dependency vẫn được cache, chỉ build lại phần code thay đổi.
- COPY package*.json trước, RUN npm ci, rồi mới COPY . . — thứ tự ngược lại sẽ vô hiệu hoá cache mỗi khi sửa 1 dòng code bất kỳ.
- Dùng .dockerignore để loại node_modules, .git, .env, *.log ra khỏi build context — build context nhỏ hơn, build nhanh hơn, tránh copy nhầm secret.
- Chạy container với USER không phải root (ví dụ USER node) — giảm thiệt hại nếu container bị chiếm quyền.
- Dùng image base -alpine hoặc -slim thay vì bản đầy đủ, trừ khi cần thư viện hệ thống mà alpine không có sẵn.
Kết quả thực tế: một image Node.js/Next.js viết theo kiểu single-stage thường rơi vào khoảng 800MB-1.2GB; sau khi áp dụng multi-stage + alpine + standalone output, cùng ứng dụng đó có thể giảm còn 120-200MB — kéo theo thời gian pull image trong CI/CD giảm đáng kể, đặc biệt quan trọng với deploy kiểu Blue/Green cần pull image mới mỗi lần release.