Mở đầu — vì sao bài này quan trọng
Nếu bạn đã đi qua các bài trước về deployment cho backend — rolling update, blue-green, canary trên Kubernetes — thì khi bắt tay vào deploy một ứng dụng frontend, bạn sẽ nhanh chóng nhận ra: mọi thứ khác hẳn. Không có pod, không có replica, không có health check kiểu /healthz, không có container đang chạy nhận traffic. Thứ bạn "deploy" chỉ là một mớ file tĩnh: index.html, vài file JavaScript đã bundle, CSS, ảnh, font.
Chính vì cái output "đơn giản" đó mà nhiều bạn kỹ sư chủ quan, rồi vấp phải những sự cố rất khó chịu: người dùng mở web thấy trang trắng, gọi API trả về file JS cũ đã bị xóa, deploy xong nửa tiếng vẫn thấy phiên bản cũ vì CDN chưa purge cache. Đây là những lỗi kinh điển của CD cho frontend, và chúng bắt nguồn từ việc không hiểu bản chất khác biệt giữa "deploy một service đang chạy" và "phân phối một tập file tĩnh qua mạng lưới CDN toàn cầu".
Bài này tập trung hoàn toàn vào CD cho frontend — cụ thể là static site (trang tĩnh, blog, landing page, tài liệu) và SPA (Single Page Application như React, Vue, Angular). Bạn sẽ hiểu vì sao caching và cache invalidation là "linh hồn" của frontend deployment, cách content hashing giải quyết bài toán versioning, và vì sao preview deployment lại là thứ thay đổi hoàn toàn trải nghiệm review code của cả team. Đây là kiến thức bạn dùng gần như hàng ngày nếu làm việc với bất kỳ sản phẩm web nào.
Khái niệm cốt lõi
Output của frontend là file tĩnh, không phải process
Khi build một app React với npm run build, kết quả là một thư mục dist/ (hoặc build/) chứa các file HTML, JS, CSS đã được minify và bundle. Không có server nào phải "chạy" ứng dụng này — trình duyệt của người dùng mới là nơi thực thi JavaScript. Vai trò của hạ tầng phía bạn chỉ là phục vụ (serve) các file này càng nhanh càng tốt cho người dùng trên toàn thế giới.
Điều này thay đổi hoàn toàn kiến trúc deploy. Thay vì đẩy image lên registry rồi cập nhật deployment trên K8s, bạn upload file lên một object storage (S3, Cloud Storage, R2) và để một CDN đứng trước phân phối. Hoặc bạn dùng nền tảng chuyên biệt như Vercel, Netlify, Cloudflare Pages — vốn được thiết kế riêng cho đúng mô hình này.
CDN và bài toán biên (edge)
CDN (Content Delivery Network) là mạng lưới máy chủ đặt rải rác khắp thế giới. Khi người dùng ở TP.HCM mở web, họ được phục vụ file từ điểm edge gần nhất — ví dụ node ở Singapore hoặc thậm chí ngay tại Việt Nam — thay vì phải kéo file từ server gốc ở Mỹ. Kết quả là tốc độ tải nhanh, độ trễ thấp.
Nhưng đổi lại, CDN cache file. Đây vừa là điểm mạnh vừa là con dao hai lưỡi. File được cache ở hàng trăm điểm edge, và khi bạn deploy phiên bản mới, các bản cache cũ không tự động biến mất. Đây chính là gốc rễ của mọi vấn đề frontend CD.
Cache và cache invalidation — trái tim của frontend CD
Có hai loại file với chiến lược cache đối lập nhau, và hiểu đúng chỗ này là bạn đã giải quyết được 80% vấn đề:
File có content hash (JS, CSS, ảnh): Các build tool hiện đại (Vite, Webpack) đặt tên file kèm hash nội dung, ví dụ main.a3f9c2b1.js. Nếu nội dung thay đổi, hash đổi, tên file đổi. Vì tên file là duy nhất theo nội dung, bạn có thể cache chúng vĩnh viễn với header:
Cache-Control: public, max-age=31536000, immutable
immutable báo cho trình duyệt "file này không bao giờ đổi, đừng bao giờ hỏi lại server". Khi có bản mới, tên file mới sẽ được tạo ra, nên không có xung đột.
File index.html: Đây là "cửa ngõ" trỏ tới các file JS/CSS có hash. File này không được cache lâu, vì mỗi lần deploy nó phải trỏ tới tên file JS/CSS mới. Cấu hình đúng là:
Cache-Control: no-cache
no-cache (khác với no-store) nghĩa là trình duyệt vẫn lưu bản, nhưng luôn hỏi server "bản này còn mới không?" trước khi dùng. Nhờ vậy người dùng luôn nhận index.html mới nhất, mà index.html mới lại trỏ tới các asset đã được cache sẵn.
Đây gọi là chiến lược cache-forever + invalidate-the-entry-point. Cực kỳ quan trọng, và là chuẩn công nghiệp cho SPA.
Cache invalidation ở tầng CDN
Ngay cả khi đã cấu hình header đúng, tầng CDN vẫn giữ bản cache riêng theo TTL của nó. Sau khi deploy, bạn cần chủ động purge (invalidate) cache CDN cho ít nhất index.html. Với AWS CloudFront là lệnh create-invalidation, với Cloudflare là purge cache qua API. Bỏ qua bước này là lý do phổ biến nhất khiến "deploy xong mà vẫn thấy bản cũ".
Preview deployment — deploy cho từng Pull Request
Một đặc sản của frontend CD: mỗi Pull Request được deploy tự động thành một URL riêng, ví dụ pr-142.preview.myapp.com. Reviewer, PM, designer có thể mở link, click thử, xem giao diện thật mà không cần checkout code hay chạy local. Vì output là file tĩnh và deploy rất rẻ, việc tạo hàng chục môi trường preview song song gần như không tốn kém — điều bất khả thi với backend nặng nề. Đây là thứ Vercel/Netlify làm nổi tiếng và đã trở thành kỳ vọng mặc định.
Tình huống thực tế
Tình huống 1 — Trang trắng sau deploy tại một startup fintech ở Hà Nội
Một startup fintech có app quản lý ví điện tử viết bằng React, host trên S3 + CloudFront. Đội deploy bằng cách sync toàn bộ dist/ lên S3 rồi purge cache. Một ngày, sau khi deploy lúc 10 giờ sáng — giờ cao điểm — hàng loạt người dùng báo màn hình trắng, console báo lỗi Failed to load chunk main.7c2a.js — 404.
Nguyên nhân: quy trình deploy của họ xóa hết file cũ trước, rồi upload file mới. Trong vài giây giữa hai bước, một người dùng đang mở app tải index.html mới (trỏ tới chunk mới main.9f1b.js), nhưng khi trình duyệt lazy-load các chunk phụ, những chunk đó chưa kịp upload xong hoặc người dùng vẫn còn index.html cũ đang cache trong tab, trỏ tới main.7c2a.js vừa bị xóa.
Bài học rút ra: luôn upload file mới TRƯỚC, purge index.html SAU, và KHÔNG xóa ngay file cũ. Giữ lại các chunk cũ thêm vài phiên bản (grace period) để những tab đang mở của người dùng không bị đứt gãy. Đội này sau đó chuyển sang chiến lược "upload assets có hash trước → invalidate index.html → dọn file cũ sau 7 ngày", và sự cố trang trắng biến mất hoàn toàn.
Tình huống 2 — Sàn thương mại điện tử và bài học purge cache
Một sàn thương mại điện tử tại Đông Nam Á (giả định, quy mô ~2 triệu lượt truy cập/ngày) chạy landing page khuyến mãi Flash Sale. Team marketing yêu cầu đổi banner và giá lúc 12h trưa đúng giờ sale. Đội frontend deploy bản mới lúc 11h55, xác nhận trên máy mình đã thấy banner mới. Nhưng đến 12h, khách hàng vẫn thấy banner cũ suốt gần 40 phút.
Nguyên nhân: họ cache index.html với max-age=3600 (1 giờ) ở tầng CDN, và quên gọi purge cache sau deploy. CDN vẫn phục vụ bản HTML cũ đến khi TTL hết. 40 phút Flash Sale hiển thị sai giá trong ngày cao điểm là thiệt hại doanh thu thực sự.
Bài học: với file entry-point như index.html, đặt Cache-Control: no-cache (hoặc max-age=0, must-revalidate) và tự động hóa bước purge cache CDN ngay trong pipeline, không để thủ công. Sau sự cố, họ thêm một step aws cloudfront create-invalidation --paths "/index.html" "/" vào cuối job deploy và đặt cảnh báo nếu invalidation thất bại.
Tình huống 3 — Preview deployment cứu team agency
Một agency phần mềm ở Đà Nẵng làm web cho khách hàng nước ngoài. Trước đây, mỗi lần dev làm xong tính năng, khách muốn xem thì phải chờ merge vào staging, rồi deploy staging — mất cả buổi, và nếu hai dev cùng đẩy lên staging thì đè nhau. Review chậm, khách hàng ở múi giờ khác thường phải chờ tới hôm sau.
Họ chuyển sang Cloudflare Pages với preview deployment. Giờ mỗi PR tự sinh một URL riêng dạng abc123.project.pages.dev, tự động comment vào PR trên GitHub. Khách hàng chỉ cần click link là xem được đúng nhánh đó, không đụng chạm gì staging. Thời gian từ "làm xong" đến "khách duyệt" giảm từ trung bình 1 ngày xuống còn khoảng 2 giờ.
Bài học: preview deployment không chỉ là tính năng kỹ thuật, nó thay đổi cả quy trình cộng tác. Với frontend, chi phí tạo môi trường preview gần như bằng 0, nên hãy tận dụng triệt để — biến review giao diện thành thao tác click chuột thay vì dựng môi trường.
Hướng dẫn từng bước
Dưới đây là cách dựng một pipeline CD hoàn chỉnh cho SPA host trên S3 + CloudFront bằng GitHub Actions. Nguyên lý áp dụng được cho mọi nền tảng.
Bước 1 — Build với content hashing. Đảm bảo build tool (Vite/Webpack) tạo tên file kèm hash. Vite làm điều này mặc định, output ra dist/assets/index-a3f9c2b1.js. Kiểm tra thư mục dist/ để chắc chắn.
Bước 2 — Cấu hình pipeline. Job build chạy trên mỗi push:
name: Deploy Frontend
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run build
Bước 3 — Upload assets có hash TRƯỚC, với cache dài. Đây là bước then chốt, upload theo đúng thứ tự:
- name: Upload hashed assets
run: |
aws s3 sync dist/ s3://my-bucket/ \
--exclude "index.html" \
--cache-control "public,max-age=31536000,immutable"
Lưu ý --exclude "index.html" — ta chưa đụng tới nó.
Bước 4 — Upload index.html với no-cache. Sau khi mọi asset đã lên xong:
- name: Upload index.html
run: |
aws s3 cp dist/index.html s3://my-bucket/index.html \
--cache-control "no-cache"
Bước 5 — Purge cache CDN. Invalidate entry-point trên CloudFront:
- name: Invalidate CDN
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CF_DIST_ID }} \
--paths "/" "/index.html"
Bước 6 — Cấu hình SPA fallback. SPA dùng client-side routing, nên khi người dùng truy cập thẳng /products/123, server phải trả về index.html (không phải 404) để router phía client xử lý. Trên CloudFront, cấu hình custom error response: mã 403/404 → trả /index.html với status 200. Trên Netlify là file _redirects với dòng /* /index.html 200.
Bước 7 — Thêm preview deployment (tuỳ chọn). Với nền tảng như Vercel/Netlify/Cloudflare Pages, bật preview là mặc định. Nếu tự dựng trên S3, bạn có thể deploy mỗi PR vào một prefix riêng (s3://my-bucket/pr-142/) và tạo URL tương ứng, rồi comment link vào PR.
Lỗi thường gặp & mẹo
Cache index.html quá lâu. Lỗi phổ biến nhất. Nếu index.html bị cache dài, người dùng mãi trỏ tới asset cũ. Luôn để no-cache cho entry-point.
Quên purge CDN. Header đúng nhưng không invalidate CDN vẫn thấy bản cũ theo TTL. Tự động hóa bước purge trong pipeline, đừng làm tay.
Xóa file cũ ngay lập tức. Dùng aws s3 sync --delete một cách mù quáng sẽ xóa các chunk cũ mà tab đang mở của người dùng còn cần, gây lỗi 404 chunk. Giữ file cũ vài phiên bản, dọn định kỳ sau.
Không đặt SPA fallback. Người dùng refresh trên route sâu bị 404. Luôn cấu hình fallback về index.html.
Rò rỉ biến môi trường build-time. Frontend build là file tĩnh gửi thẳng cho trình duyệt. Bất cứ biến nào nhúng vào lúc build (ví dụ VITE_API_KEY) đều lộ công khai cho bất kỳ ai xem source. Đừng bao giờ nhúng secret nhạy cảm; chỉ đưa vào những giá trị public (URL API, key analytics công khai).
Mẹo — kiểm tra cache header bằng curl. Sau deploy, chạy curl -I https://myapp.com/index.html và xem header cache-control, age, x-cache để xác nhận cấu hình đúng và biết request đang trúng cache CDN hay không.
Mẹo — thêm smoke test sau deploy. Sau khi purge, gọi thử URL production và kiểm tra index.html trả về đúng tham chiếu asset mới, tránh phát hiện lỗi qua người dùng.
Bài tập thực hành
- Phân tích một app thật. Mở một website SPA bất kỳ (ví dụ một app React lớn), bật DevTools tab Network, hard-refresh. Tìm file
index.htmlvà các file JS có hash. Ghi lại headerCache-Controlcủa từng loại. Chúng có tuân theo chiến lược cache-forever + no-cache-entry không?
- Dựng pipeline mini. Tạo một app Vite đơn giản, viết GitHub Actions workflow build và sync lên S3 (hoặc dùng Cloudflare Pages/Netlify miễn phí). Đảm bảo upload asset trước,
index.htmlsau, và có bước purge cache.
- Tái hiện lỗi trang trắng. Cố tình cache
index.htmlvớimax-age=31536000, deploy 2 phiên bản khác nhau và quan sát vì sao người dùng kẹt ở bản cũ. Sau đó sửa thànhno-cachevà so sánh.
- Bật preview deployment. Tạo một PR trên repo dùng Netlify/Vercel/Cloudflare Pages, xác nhận URL preview tự sinh và comment vào PR. Viết ngắn 3-4 câu về cách nó thay đổi quy trình review của bạn.
Tóm tắt
CD cho frontend khác biệt căn bản với backend: output là file tĩnh được phân phối qua CDN, không phải process chạy trong pod. Vì vậy, thay vì lo về replica hay health check, bạn phải nắm vững hai thứ cốt lõi: caching và cache invalidation. Nguyên tắc vàng là cache vĩnh viễn các asset có content hash (immutable) và không cache entry-point index.html (no-cache), kết hợp purge CDN tự động sau mỗi deploy. Thứ tự upload cũng quan trọng: asset trước, index.html sau, và đừng vội xóa file cũ để tránh lỗi 404 chunk cho các tab đang mở. Cuối cùng, tận dụng preview deployment — đặc sản gần như miễn phí của frontend — để biến việc review giao diện thành thao tác click chuột. Nắm chắc những nguyên lý này, bạn sẽ tránh được đúng những sự cố mà các tình huống thực tế ở trên đã minh họa, và deploy frontend một cách tự tin, an toàn.