Vấn đề thường gặp khi Dockerize frontend
Lần đầu mình dùng Docker cho dự án thực tế — một app React khá lớn — mình đã viết Dockerfile kiểu… copy toàn bộ source vào image, rồi chạy npm run build bên trong. Image build ra nặng tới hơn 1GB, mỗi lần sửa một dòng CSS là phải build lại từ đầu, tốn cả chục phút. Bây giờ nghĩ lại thấy buồn cười thật.
Chưa kể, sau khi deploy lên server, người dùng refresh trang con thì nhận ngay lỗi 404 Not Found từ Nginx vì Nginx không biết xử lý route /dashboard/settings — nó cứ đi tìm file tĩnh với đường dẫn đó mà không thấy.
Image nặng/build chậm và routing 404 là hai lỗi cực kỳ phổ biến khi mới bắt đầu Dockerize frontend. Bài này mình sẽ chia sẻ cách giải quyết dứt điểm cả hai.
Khái niệm cốt lõi cần nắm
Multi-stage build là gì?
Docker cho phép dùng nhiều FROM trong một Dockerfile. Giai đoạn đầu dùng image Node để build, giai đoạn sau dùng image Nginx nhỏ gọn để serve file tĩnh. Image cuối cùng chỉ chứa output build — không có Node, không có node_modules. Kết quả? Thường chỉ còn khoảng 20–30MB thay vì hàng trăm MB như trước.
Layer caching hoạt động ra sao?
Docker build theo từng lớp (layer). Nếu một layer không thay đổi so với lần build trước, Docker dùng lại cache — không build lại. Mấu chốt là thứ tự các lệnh trong Dockerfile: đặt những thứ ít thay đổi (cài dependencies) lên trước, những thứ hay thay đổi (source code) xuống sau. Nhờ vậy, lần build tiếp theo chỉ re-run từ layer bị thay đổi trở đi — bước npm ci vốn tốn 2–3 phút sẽ được cache hoàn toàn.
Client-side routing và Nginx
Các framework frontend hiện đại (React Router, Vue Router, Angular Router) quản lý routing phía client. Toàn bộ app chỉ có một file index.html duy nhất. Khi người dùng vào /about, JavaScript xử lý route đó — nhưng Nginx không biết điều này. Nó sẽ tìm file /about/index.html trên disk, không thấy thì trả 404. Cần cấu hình Nginx để fallback về index.html cho mọi route không khớp file thật.
Thực hành chi tiết
1. Dockerfile tối ưu với multi-stage build
Template mình đang dùng cho các dự án React/Vue/Angular:
# ---- Stage 1: Build ----
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files TRƯỚC — để cache layer này
# Chỉ re-run khi package.json hoặc lock file thay đổi
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
# Copy source code SAU — layer này thay đổi thường xuyên hơn
COPY . .
RUN npm run build
# ---- Stage 2: Serve ----
FROM nginx:1.27-alpine
# Copy output build từ stage 1
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy cấu hình Nginx tùy chỉnh
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Lưu ý về thư mục output:
- React (Vite):
dist/ - React (CRA):
build/ - Vue (Vite):
dist/ - Angular:
dist/<project-name>/browser/
Sửa đường dẫn COPY --from=builder /app/dist cho đúng với framework của bạn.
2. File .dockerignore — đừng bỏ qua
Nhiều người quên file này. Thiếu nó, Docker copy cả node_modules (dễ vài trăm MB) vào build context mỗi lần — chỉ riêng bước này có thể làm build context phình lên 500MB+ và chậm đi đáng kể trước khi build bắt đầu.
node_modules
dist
build
.git
.env
.env.local
.env.*.local
*.log
.DS_Store
3. Cấu hình Nginx xử lý client-side routing
File nginx.conf dưới đây đủ dùng cho hầu hết các dự án, đã xử lý cả routing lẫn caching:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Bật gzip để giảm bandwidth
gzip on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1024;
# Cache aggressively cho static assets có hash trong tên file
# VD: main.a3f92b1c.js — hash thay đổi khi code thay đổi
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# QUAN TRỌNG: fallback về index.html cho client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# Không cache index.html — để browser luôn lấy phiên bản mới nhất
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
}
Dòng try_files $uri $uri/ /index.html; là chìa khóa: Nginx thử tìm file thật trước, nếu không thấy thì trả về index.html — để React/Vue/Angular Router xử lý tiếp.
4. Chạy với Docker Compose
Khi frontend là một phần của stack lớn hơn (có backend, database…), kết hợp với Docker Compose như sau:
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:80"
environment:
- NODE_ENV=production
restart: unless-stopped
backend:
build: ./backend
ports:
- "8000:8000"
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: myapp
POSTGRES_USER: user
POSTGRES_PASSWORD: secret
volumes:
pgdata:
5. Truyền biến môi trường vào frontend lúc build
Điểm này khác hoàn toàn so với backend: biến môi trường của React/Vue/Angular được nhúng vào code lúc build, không phải lúc runtime. Vậy nên cần truyền vào stage builder, không phải stage Nginx:
# Trong Dockerfile, thêm ARG và ENV
FROM node:20-alpine AS builder
WORKDIR /app
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
Khi build, truyền giá trị qua --build-arg:
docker build \
--build-arg VITE_API_URL=https://api.yourdomain.com \
-t frontend:latest .
Hoặc trong docker-compose.yml:
services:
frontend:
build:
context: ./frontend
args:
VITE_API_URL: https://api.yourdomain.com
6. Kiểm tra layer caching thực tế
Sau khi viết Dockerfile đúng thứ tự, thử build lần đầu rồi chỉ sửa một file component nhỏ và build lại. Output sẽ trông như này:
$ docker build -t frontend:v2 .
[1/6] FROM node:20-alpine # Cached
[2/6] WORKDIR /app # Cached
[3/6] COPY package.json ... # Cached ← không đổi package.json
[4/6] RUN npm ci # Cached ← bước tốn thời gian nhất, được cache!
[5/6] COPY . . # Rebuilt ← source code thay đổi
[6/6] RUN npm run build # Rebuilt
Bước npm ci — vốn tốn 2–3 phút — chỉ chạy lại khi bạn thêm hoặc xóa package. Build thông thường sẽ chỉ mất 30–60 giây thay vì cả chục phút như trước.
Kết luận
Nếu phải chọn hai thứ quan trọng nhất khi Dockerize frontend, mình sẽ chọn: thứ tự layer trong Dockerfile và cấu hình try_files trong Nginx. Nắm được hai điểm này là tránh được phần lớn những vấn đề hay gặp nhất.
Multi-stage build không chỉ giúp image nhỏ gọn — container production không có Node.js, không có source code gốc, giảm attack surface đáng kể. Nhỏ hơn và an toàn hơn cùng một lúc.
Nếu bạn đang dùng Nginx làm reverse proxy cho cả frontend lẫn backend trên cùng một server, có thể thêm cấu hình location /api để proxy request tới backend — nhưng đó là chủ đề cho một bài khác.
