Kinh nghiệm
6 phút đọc15 tháng 6, 2026361

Dockerfile chuyên nghiệp: Từ cơ bản đến multi-stage build

Viết Dockerfile đúng cách để build image nhỏ, nhanh, bảo mật từ layer ordering đến multi-stage build cho Next.js.

N

Nguyễn Nhật Long

@nguyennhatlong1303

Dockerfile chuyên nghiệp: Từ cơ bản đến multi-stage build

Bài trước mình đã nói về image layers cách Docker cache từng layer và tại sao thứ tự các instruction lại quan trọng. Bài này mình sẽ đi thẳng vào việc viết Dockerfile thực tế, những pattern mình dùng hằng ngày, và cả những lỗi kinh điển mà ai cũng từng mắc ít nhất một lần.

Các instruction cần biết trước khi viết bất cứ thứ gì

Dockerfile không có nhiều instruction lắm, nhưng hiểu rõ từng cái thì quan trọng hơn là nhớ hết syntax:

Cái hay nhất là ENTRYPOINT vs CMD hai cái này hay bị nhầm lẫn. Hiểu đơn giản thế này: ENTRYPOINT là cái bạn không muốn ai thay đổi (ví dụ: binary chính của app), còn CMD là default arguments có thể override. Kết hợp cả hai thì ENTRYPOINT chạy trước, CMD là arguments mặc định truyền vào.

InstructionTác dụngGhi chú
`FROM`Chọn base imageBắt buộc, phải là dòng đầu tiên
`WORKDIR`Set working directoryTự tạo folder nếu chưa có
`COPY`Copy file từ host vào imageNên dùng thay vì `ADD`
`RUN`Chạy command lúc buildMỗi `RUN` tạo một layer mới
`CMD`Command mặc định khi run containerCó thể override khi `docker run`
`ENTRYPOINT`Command cố định khi run containerKhông override được dễ dàng
`EXPOSE`Document portChỉ để document, không thực sự mở port
`ENV`Set environment variableTồn tại cả lúc build lẫn runtime
`ARG`Build-time variableChỉ tồn tại lúc build, không có trong container

Dockerfile cho Node.js app và tại sao thứ tự lại quan trọng đến vậy

Mình lấy ví dụ một Node.js app đơn giản:

Dockerfile
1FROM node:20-alpine
2WORKDIR /app
3COPY package*.json ./
4RUN npm ci --only=production
5COPY . .
6EXPOSE 3000
7CMD ["node", "server.js"]

Nhìn vào đây, bạn có thể thấy mình copy package*.json trước, rồi mới RUN npm ci, rồi mới COPY . .. Tại sao không COPY . . luôn một lần cho nhanh?

Vì Docker cache layer theo thứ tự. Khi bạn thay đổi một file source code bất kỳ, nếu COPY . . nằm trước npm ci, Docker sẽ invalidate cache từ đó trở đi tức là npm ci chạy lại từ đầu mỗi lần bạn sửa code. Với node_modules vài trăm MB, điều đó cực kỳ tốn thời gian.

Thứ tự đúng: những gì ít thay đổi thì để trên, những gì hay thay đổi thì để dưới. package.json chỉ thay đổi khi bạn thêm/xóa dependency, còn source code thay đổi liên tục nên source code phải ở dưới cùng.

Theo kinh nghiệm của mình, cái lỗi này là nguyên nhân số một khiến CI pipeline chậm không thể giải thích được. Build local thì nhanh vì có cache, nhưng CI runner fresh mỗi lần thì không có cache và đó là lúc bạn nhận ra mình đã viết Dockerfile sai từ đầu.

.dockerignore đừng bỏ qua file này

Trước khi nói đến multi-stage, mình muốn nhắc đến .dockerignore vì nhiều người hay quên. File này hoạt động giống .gitignore liệt kê những gì Docker không copy vào image khi bạn dùng COPY . ..

TEXT
1node_modules
2.git
3.env
4.env.local
5.next
6dist
7coverage
8*.log
9.DS_Store
10README.md

Anh em lưu ý: nếu không có .dockerignore, COPY . . sẽ copy cả node_modules vào build context và Docker phải gửi toàn bộ đống đó lên Docker daemon trước khi build. Cái này không chỉ chậm mà còn có thể gây ra bug kỳ lạ vì node_modules trên host và trong container có thể khác nhau (đặc biệt là native modules).

Multi-stage build kỹ thuật quan trọng nhất bạn cần biết

Đây là phần mình thích nhất. Multi-stage build cho phép bạn dùng nhiều FROM trong một Dockerfile, mỗi FROM là một stage riêng biệt. Stage sau có thể copy artifacts từ stage trước và image cuối cùng chỉ chứa những gì stage cuối cùng có.

Tại sao cần điều này? Vì để build một Next.js app, bạn cần: Node.js, npm, toàn bộ devDependencies, TypeScript compiler, webpack... Nhưng để chạy app đó, bạn chỉ cần: Node.js và output đã được build. Tất cả thứ còn lại là rác.

Mình lấy ví dụ thực tế với Next.js:

Dockerfile
1# Stage 1: Build
2FROM node:20-alpine AS builder
3WORKDIR /app
4COPY package*.json ./
5RUN npm ci
6COPY . .
7RUN npm run build
8
9# Stage 2: Run
10FROM node:20-alpine AS runner
11WORKDIR /app
12COPY --from=builder /app/.next/standalone ./
13COPY --from=builder /app/.next/static ./.next/static
14COPY --from=builder /app/public ./public
15EXPOSE 3000
16CMD ["node", "server.js"]

Kết quả? Image size giảm từ ~1GB xuống còn khoảng 100-150MB. Mình đã áp dụng cái này cho một project Next.js ở công ty và thời gian pull image trên production giảm rõ rệt, đặc biệt khi deploy lên các region xa.

Một điểm quan trọng với Next.js: bạn cần bật output: 'standalone' trong next.config.js thì mới có thư mục .next/standalone. Nếu không có cái này, multi-stage build vẫn work nhưng bạn sẽ phải copy thêm nhiều thứ hơn.

JavaScript
1// next.config.js
2module.exports = {
3 output: 'standalone',
4}

Gộp RUN commands ít layers, image nhỏ hơn

Mỗi RUN instruction tạo một layer mới. Nhiều layer không phải lúc nào cũng xấu, nhưng với những command setup môi trường, bạn nên gộp lại:

Dockerfile
1# Không tốt tạo 3 layers riêng biệt
2RUN apt-get update
3RUN apt-get install -y curl
4RUN rm -rf /var/lib/apt/lists/*
5
6# Tốt hơn một layer duy nhất
7RUN apt-get update && \
8 apt-get install -y curl && \
9 rm -rf /var/lib/apt/lists/*

Cái rm -rf /var/lib/apt/lists/* ở cuối là quan trọng nếu bạn để nó ở một RUN riêng, Docker vẫn giữ cache của apt trong layer trước, và xóa ở layer sau không thực sự giảm size của image vì layer cũ vẫn còn đó.

Chạy container với non-root user

Đây là best practice về security mà mình thấy nhiều team bỏ qua vì nghĩ "dev environment thôi, cần gì". Nhưng thói quen tốt nên build từ đầu:

Dockerfile
1FROM node:20-alpine
2WORKDIR /app
3
4# Tạo user riêng
5RUN addgroup --system appgroup && \
6 adduser --system --ingroup appgroup appuser
7
8COPY --chown=appuser:appgroup package*.json ./
9RUN npm ci --only=production
10COPY --chown=appuser:appgroup . .
11
12# Switch sang non-root user
13USER appuser
14
15EXPOSE 3000
16CMD ["node", "server.js"]

Lý do đơn giản: nếu có security vulnerability trong app của bạn và attacker có thể execute code trong container, họ sẽ chạy với quyền root nếu bạn không set USER. Với non-root user, thiệt hại bị giới hạn đáng kể hơn.

Pin version đừng tin vào latest

Cái này mình học được từ một incident khá đau: FROM node:20-alpine một ngày nào đó sẽ pull một minor version khác và app của bạn có thể break vì behavior thay đổi. Pin version cụ thể:

Dockerfile
1# Không nên
2FROM node:latest
3FROM node:20-alpine
4
5# Nên dùng
6FROM node:20.11.1-alpine3.19

Mình biết là pin version cụ thể thì phải update thủ công, nhưng đó là trade-off đáng chấp nhận. Bạn có thể dùng Dependabot hoặc Renovate để tự động tạo PR update base image vừa có control, vừa không quá tốn công.

Một Dockerfile hoàn chỉnh để tham khảo

Tổng hợp lại tất cả những gì mình vừa nói, đây là Dockerfile mình hay dùng cho Node.js production app:

Dockerfile
1FROM node:20.11.1-alpine3.19 AS builder
2WORKDIR /app
3
4COPY package*.json ./
5RUN npm ci
6
7COPY . .
8RUN npm run build
9
10# ---
11
12FROM node:20.11.1-alpine3.19 AS runner
13WORKDIR /app
14
15RUN addgroup --system appgroup && \
16 adduser --system --ingroup appgroup appuser
17
18COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
19COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
20COPY --from=builder --chown=appuser:appgroup /app/package.json ./
21
22USER appuser
23
24EXPOSE 3000
25CMD ["node", "dist/server.js"]

Bài tiếp theo mình sẽ nói về Docker Compose cách orchestrate nhiều container cùng lúc cho local development. Đó là lúc mọi thứ bắt đầu thú vị hơn nhiều khi bạn cần chạy app + database + redis + một đống services khác chỉ bằng một command.

NN

Nguyễn Nhật Long

@nguyennhatlong1303

Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.

Thấy hay? Chia sẻ cho bạn bè!

Dockerfile chuyên nghiệp: Từ cơ bản đến multi-stage build — Stacklog