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
Cloudflare WorkersCloudflareNext.js 16CI/CDGitHub Actions
Thư mục out/ đi thẳng lên mạng edge của Cloudflare, bên cạnh là VPS chạy nginx bị gạch bỏ

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:

json
{
  "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.htmlhtml_handling: "auto-trailing-slash"
location ~ ^/blog/([^./]+)$ { return 301 /$1; }dòng /blog/:slug /:slug 301 trong public/_redirects
error_page 404 /not-found.htmlnot_found_handling: "404-page", cộng một bước chép file
add_header Cache-Control ... theo từng locationkhối đường dẫn trong public/_headers
www về apexRedirect 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

text
/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:

yaml
      - 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:

text
/*
  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>.html trả 307 về URL không đuôi
  • curl -sI https://<site>/blog/<slug> trả 301, /blog/page/2 trả 200
  • curl -sI https://<site>/khong-ton-tai trả 404 kèm nội dung trang not-found
  • curl -sI https://<site>/_next/static/... chỉ có một Cache-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:

bash
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:

yaml
      - 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:

bash
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 →