blog
Bỏ server: chuyển site Next.js tĩnh sang Cloudflare Workers Static Assets
Site này chỉ là HTML, JS và ảnh, vậy mà từng nằm trên một VPS 2 GB RAM chung với khoảng 28 site khác. Cách chuyển nó sang Workers Static Assets: dịch cấu hình nginx sang _redirects và _headers, vì sao request không tính vào hạn mức gói Free, và hai chỗ vấp sau ngày chuyển.
- Ngày đăng
Site nguyentrungkhuong.com là Next.js output: 'export': build xong chỉ còn HTML, JS và ảnh trong out/. Nó không cần Node, không cần PHP, vậy mà tới đầu tháng 10/2026 vẫn nằm trên một VPS chạy nginx kèm self-hosted runner của GitHub Actions. Ngày 14/10/2026 tôi chuyển nó sang Cloudflare Workers Static Assets. Giờ không còn server nào để vá, và mọi trang của site đều là file tĩnh, loại request gói Free không tính vào hạn mức.
Site tĩnh trên VPS chung: tốn RAM lúc build, tốn công bảo trì
VPS đó có 2 GB RAM, chạy chung khoảng 28 site và đã ăn vào swap. next build cần 1–2 GB RAM, nên mỗi lần deploy là một lần tranh RAM với các site khác. Ngày 12/10/2026 tôi tách bước build sang runner ubuntu-latest của GitHub, server chỉ còn nhận artifact rồi đổi symlink như cách mô tả trong bài dựng CI/CD build static trên server.
Tách xong thì phần còn lại trên server chỉ là nginx phục vụ file, cộng một runner phải cập nhật và một hệ điều hành phải vá. Site vốn đã đi qua proxy của Cloudflare, nên để chính Cloudflare phục vụ file là bước kế tiếp: request không còn chặng chạy về VPS.
Đo từ máy tôi ở Việt Nam ngày 28/10/2026, năm lần mỗi URL: TTFB trung vị khoảng 120ms cho /, /blog và một trang bài, header cf-cache-status: HIT, request đi qua điểm HKG. Tôi không có số đo cùng kiểu trước khi chuyển, nên con số này không chứng minh site nhanh hơn trước.
Static Assets không gọi Worker: miễn phí, không tính vào 100.000 request/ngày
Worker khuong không có code. File wrangler.jsonc chỉ khai thư mục asset, không có main:
{
"name": "khuong",
"compatibility_date": "2026-09-01",
"workers_dev": false,
"preview_urls": false,
"routes": [
{ "pattern": "nguyentrungkhuong.com/*", "zone_name": "nguyentrungkhuong.com" }
],
"assets": {
"directory": "./out",
"html_handling": "auto-trailing-slash",
"not_found_handling": "404-page"
}
}
Chính chỗ thiếu main quyết định chi phí. Trang billing của Static Assets ghi "Requests to static assets are free and unlimited". Hạn mức 100.000 request/ngày của gói Free, đặt lại lúc 00:00 UTC và tính chung cả account, chỉ đếm request chạy code Worker. Số liệu GraphQL workersInvocationsAdaptive từ 21 tới 28/10/2026 xác nhận điều đó: khuong và khuong-cdn (Worker của cdn.nguyentrungkhuong.com) không có lần chạy nào, trong khi apex nhận khoảng 3.500 request/ngày.
Gói Free còn hai giới hạn cho asset: 20.000 file mỗi version và 25 MiB mỗi file. Bản build hiện có 265 file, còn xa trần.
Dịch cấu hình nginx sang _redirects, _headers và wrangler.jsonc
Mọi thứ nginx từng làm đều có chỗ tương ứng. _redirects và _headers đặt trong public/, Next chép chúng vào out/ lúc export, nên chúng đi cùng mỗi bản build.
| nginx cũ | Workers Static Assets |
|---|---|
try_files $uri $uri.html $uri/index.html | html_handling: "auto-trailing-slash" |
location ~ ^/blog/([^./]+)$ { return 301 /$1; } | dòng /blog/:slug /:slug 301 trong public/_redirects |
error_page 404 /not-found.html | not_found_handling: "404-page", cộng một bước chép file |
add_header Cache-Control ... theo từng location | khối đường dẫn trong public/_headers |
www về apex | Redirect Rule của zone, ngoài Worker |
auto-trailing-slash thay try_files, kèm 307 cho đuôi .html
/gioi-thieu trả out/gioi-thieu.html với mã 200, giống try_files. Khác ở chỗ gõ thẳng /gioi-thieu.html thì Cloudflare chuyển hướng 307 về /gioi-thieu, trong khi nginx trả luôn file. Mỗi trang vì thế chỉ còn một URL trả 200.
Placeholder trong _redirects chỉ khớp một segment
/blog/ /blog 301
/blog/:slug /:slug 301
/projects/:slug /:slug 301
Placeholder khớp mọi ký tự trừ dấu /, nên /blog/:slug bắt /blog/hy3-tencent-danh-gia (301 về /hy3-tencent-danh-gia) nhưng để yên /blog/page/2. Nhờ vậy trang phân trang không cần luật ngoại lệ nào, khác hẳn regex [^./]+ bên nginx. Cần khớp nhiều segment thì dùng splat *, mỗi luật được một splat. Một file chứa tối đa 2.000 redirect tĩnh và 100 redirect động.
Trang 404 phải tên là 404.html
not_found_handling: "404-page" tìm 404.html gần nhất, còn Next export trang này ra not-found.html. Workflow chép một dòng trước khi deploy:
- name: 404 page
run: cp out/not-found.html out/404.html
_headers gỡ header cũ bằng dấu !
Luật /* gắn Cache-Control 300 giây cho mọi thứ. Thư mục có hash trong tên file cần cache một năm, nên luật riêng phải gỡ header của luật chung trước:
/*
Cache-Control: public, max-age=300, s-maxage=300, stale-while-revalidate=60
/_next/static/*
! Cache-Control
Cache-Control: public, max-age=31536000, immutable
Thiếu dòng ! Cache-Control thì file trong /_next/static/ nhận cả hai giá trị nối bằng dấu phẩy.
Kiểm sau khi deploy
curl -sI https://<site>/<trang>.htmltrả 307 về URL không đuôicurl -sI https://<site>/blog/<slug>trả 301,/blog/page/2trả 200curl -sI https://<site>/khong-ton-taitrả 404 kèm nội dung trang not-foundcurl -sI https://<site>/_next/static/...chỉ có mộtCache-Control
Bot Fight Mode chặn smoke test của chính mình
Lần deploy đầu lên Workers xanh tới bước cuối thì đỏ: smoke test gọi https://nguyentrungkhuong.com/ từ runner GitHub nhận 403. Bot Fight Mode của zone thấy IP thuộc ASN 8075 (Azure, nơi runner chạy) và trả challenge. Site vẫn sống, chỉ có runner bị chặn.
Response bị challenge có header cf-mitigated: challenge, nên smoke test tách được trường hợp này khỏi lỗi thật:
headers=$(curl -sS -o /dev/null -D - "$url")
code=$(awk 'NR==1 {print $2}' <<<"$headers")
if [ "$code" = "403" ] && grep -qi '^cf-mitigated: challenge' <<<"$headers"; then
echo "::warning::$url challenged by Bot Fight Mode for the GitHub runner IP, not checked"
continue
fi
test "$code" = "200" || { echo "::error::$url returned $code"; exit 1; }
Sau đó tôi tắt Bot Fight Mode, thay bằng một luật rate limit: 20 trang mỗi 10 giây trên mỗi IP, bỏ qua cf.client.bot (bot đã xác minh như Googlebot) và mọi đường dẫn đuôi .txt (robots.txt, ads.txt). Trong 24 giờ tính tới sáng 28/10/2026, luật này trả 1.025 lượt 429, toàn là máy dò /.env và /secrets.json từ vài dải IP ở Bỉ và Hà Lan.
Route cụ thể hơn thắng, và không khớp query string
/ads.txt của site do một Worker khác trả về, gắn bằng route nguyentrungkhuong.com/ads.txt. Route đó cụ thể hơn nguyentrungkhuong.com/* của Worker khuong, nên nó được ưu tiên. Hệ quả: thêm public/ads.txt vào repo không có tác dụng gì, file đó không bao giờ được phục vụ.
Chỗ vấp nằm ở query string. Route chỉ khớp URL có query khi pattern kết thúc bằng *. curl -sI https://nguyentrungkhuong.com/ads.txt trả 200, còn /ads.txt?x=1 lọt xuống route /*, không có file nào tên đó, ra 404. Kiểm tay mà thêm ?nocache=1 để né cache thì sẽ tưởng file đã mất.
Deploy và rollback khi không còn server
.github/workflows/deploy.yml còn hai job, cả hai chạy trên ubuntu-latest. Job build chạy npm run build:static rồi upload out/ làm artifact. Job publish tải artifact về, chép trang 404, rồi:
- name: Deploy site
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
run: npx --yes "$WRANGLER" deploy --message "${GITHUB_SHA::7}"
wrangler so hash từng file và chỉ upload file đổi. Lần deploy ngày 24/10/2026 đọc 265 file, upload 31 file, bỏ qua 213 file đã có, mất 1,48 giây. Cả workflow từ lúc push tới khi smoke test xanh mất khoảng 94–98 giây, so với 106–149 giây của các lần deploy bằng self-hosted runner hồi tháng 7 và tháng 8/2026. Thời gian không phải lý do để chuyển; phần lớn vẫn nằm ở npm ci và next build.
Rollback không cần build lại. Mỗi lần deploy là một version, và workflow rollback.yml gọi:
npx --yes wrangler@4.144.0 deployments list
npx --yes wrangler@4.144.0 rollback <version-id> --message "rollback from GitHub Actions" --yes
Thư mục releases/ với symlink current trên server không còn cần nữa. Bản ghi DNS proxied của domain vẫn giữ nguyên, nên trường hợp xấu nhất chỉ cần gỡ route nguyentrungkhuong.com/* trên dashboard là traffic về lại origin cũ.
Đánh đổi khi giao hết cho Cloudflare
Site giờ phụ thuộc hoàn toàn vào một nhà cung cấp, và không còn log truy cập của nginx, chỉ còn analytics của Cloudflare. Mọi tính năng cần code phía server (form tự xử lý, API, SSR) sẽ biến request thành request bị đếm, vượt hạn mức là lỗi 1027 cho cả site. Form liên hệ và newsletter vẫn gọi thẳng Web3Forms và Mailchimp từ trình duyệt nên chưa đụng tới giới hạn này. Thêm thứ gì chạy phía server thì tôi sẽ tách nó sang Worker riêng, route riêng, để site chính vẫn là asset thuần.
Bài viết liên quan
tất cả bài viết →- Bỏ commit thư mục out/: dựng CI/CD build static ngay trên serverRepo này có 29 trên 51 commit đụng vào thư mục build output. Cách bỏ hẳn nó: self-hosted runner build ngay trên server, publish bằng một thao tác đổi symlink, purge Cloudflare, và ba cái bẫy bash chỉ lộ ra khi đem ra test.đọc tiếp →
- Loop engineering: thôi prompt agent, đi thiết kế cái vòng lặp prompt nóDựng một vòng lặp cho agent giờ mất mười lăm phút. Phần khó là nhét vào trong đó một thứ dám nói "không". Năm nước đi của một vòng, năm kiểu hỏng, và vòng lặp thật của site này đang đi được nửa đường.đọc tiếp →
- 1 route, 10+ layout — kiến trúc site Next.js chạy hoàn toàn bằng MarkdownToàn bộ site này render từ đúng 1 file route. Pipeline phía sau: frontmatter chia section, computed fields, registry layout và collection.đọc tiếp →
- Nâng cấp Next.js 15 lên 16 — Turbopack, static export và ba chỗ vấpGhi chép từ đợt nâng cấp site này lên Next.js 16: vì sao vẫn giữ static export cho Nginx, Turbopack khắt khe hơn webpack ở đâu, và ba lỗi ngốn nhiều giờ nhất.đọc tiếp →