vi

blog

Dropping the server: moving a static Next.js site to Cloudflare Workers Static Assets

This site is only HTML, JS and images, yet it used to live on a 2 GB RAM VPS shared with about 28 other sites. How I moved it to Workers Static Assets: translating the nginx config into _redirects and _headers, why requests do not count against the Free plan limit, and two places I tripped after the move.

Published
Cloudflare WorkersCloudflareNext.js 16CI/CDGitHub Actions
The out/ folder going straight to the Cloudflare edge network, next to a crossed-out VPS running nginx

nguyentrungkhuong.com is a Next.js output: 'export' site: after the build, all that is left is HTML, JS and images in out/. It needs no Node and no PHP, yet until early October 2026 it still sat on a VPS running nginx plus a self-hosted GitHub Actions runner. On 14 Oct 2026 I moved it to Cloudflare Workers Static Assets. Now there is no server left to patch, and every page of the site is a static file, the kind of request the Free plan does not count against its limit.

A static site on a shared VPS: RAM spent on builds, time spent on upkeep

That VPS had 2 GB of RAM, hosted about 28 sites and was already into swap. next build needs 1–2 GB of RAM, so every deploy was a fight for RAM with the other sites. On 12 Oct 2026 I moved the build step to GitHub's ubuntu-latest runner, and the server only received the artifact and switched a symlink, as described in the post on a CI/CD pipeline that builds static files on the server.

After that split, what remained on the server was nginx serving files, plus a runner to keep updated and an operating system to patch. The site already went through Cloudflare's proxy, so letting Cloudflare serve the files itself was the next step: requests no longer have a leg back to the VPS.

Measured from my machine in Vietnam on 28 Oct 2026, five times per URL: median TTFB about 120ms for /, /blog and one post page, header cf-cache-status: HIT, requests served from the HKG location. I have no measurement of the same kind from before the move, so this number does not prove the site is faster than it was.

Static Assets do not invoke a Worker: free, not counted against 100,000 requests/day

The khuong Worker has no code. The wrangler.jsonc file only declares the asset folder, with no 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"
  }
}

The missing main decides the cost. The Static Assets billing page says "Requests to static assets are free and unlimited". The Free plan limit of 100,000 requests/day, reset at 00:00 UTC and shared across the whole account, only counts requests that run Worker code. GraphQL workersInvocationsAdaptive data from 21 to 28 Oct 2026 confirms it: khuong and khuong-cdn (the Worker for cdn.nguyentrungkhuong.com) had zero invocations, while the apex received about 3,500 requests/day.

The Free plan has two more limits for assets: 20,000 files per version and 25 MiB per file. The current build has 265 files, far below the ceiling.

Translating the nginx config into _redirects, _headers and wrangler.jsonc

Everything nginx used to do has a counterpart. _redirects and _headers live in public/, and Next copies them into out/ at export time, so they ship with every build.

Old nginxWorkers Static Assets
try_files $uri $uri.html $uri/index.htmlhtml_handling: "auto-trailing-slash"
location ~ ^/blog/([^./]+)$ { return 301 /$1; }the line /blog/:slug /:slug 301 in public/_redirects
error_page 404 /not-found.htmlnot_found_handling: "404-page", plus a file copy step
add_header Cache-Control ... per locationpath blocks in public/_headers
www to apexa zone Redirect Rule, outside the Worker

auto-trailing-slash replaces try_files, with a 307 for the .html suffix

/gioi-thieu returns out/gioi-thieu.html with status 200, the same as try_files. The difference: typing /gioi-thieu.html directly makes Cloudflare redirect with a 307 to /gioi-thieu, while nginx served the file as is. So each page now has only one URL that returns 200.

A placeholder in _redirects matches only one segment

text
/blog/ /blog 301
/blog/:slug /:slug 301
/projects/:slug /:slug 301

A placeholder matches any character except /, so /blog/:slug catches /blog/hy3-tencent-danh-gia (301 to /hy3-tencent-danh-gia) but leaves /blog/page/2 alone. Pagination pages therefore need no exception rule, unlike the [^./]+ regex on the nginx side. To match several segments, use a splat *, one splat per rule. A file holds at most 2,000 static redirects and 100 dynamic ones.

The 404 page must be named 404.html

not_found_handling: "404-page" looks for the nearest 404.html, while Next exports that page as not-found.html. The workflow copies it with one line before deploying:

yaml
      - name: 404 page
        run: cp out/not-found.html out/404.html

_headers removes an inherited header with !

The /* rule sets a 300-second Cache-Control on everything. Folders with a hash in their file names need a one-year cache, so the specific rule has to remove the general rule's header first:

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

Without the ! Cache-Control line, files in /_next/static/ get both values joined by a comma.

Checks after deploy

  • curl -sI https://<site>/<page>.html returns a 307 to the URL without the suffix
  • curl -sI https://<site>/blog/<slug> returns 301, /blog/page/2 returns 200
  • curl -sI https://<site>/does-not-exist returns 404 with the not-found page content
  • curl -sI https://<site>/_next/static/... has only one Cache-Control

Bot Fight Mode blocked my own smoke test

The first deploy to Workers was green up to the last step, then went red: the smoke test calling https://nguyentrungkhuong.com/ from the GitHub runner got a 403. The zone's Bot Fight Mode saw an IP in ASN 8075 (Azure, where the runners live) and served a challenge. The site was up; only the runner was blocked.

A challenged response carries the header cf-mitigated: challenge, so the smoke test can tell this case apart from a real failure:

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; }

After that I turned Bot Fight Mode off and replaced it with a rate limiting rule: 20 pages per 10 seconds per IP, skipping cf.client.bot (verified bots such as Googlebot) and every path ending in .txt (robots.txt, ads.txt). In the 24 hours up to the morning of 28 Oct 2026, the rule returned 1,025 responses with status 429, all of them scanners probing /.env and /secrets.json from a few IP ranges in Belgium and the Netherlands.

The more specific route wins, and routes ignore the query string

The site's /ads.txt is served by another Worker, attached through the route nguyentrungkhuong.com/ads.txt. That route is more specific than the nguyentrungkhuong.com/* route of the khuong Worker, so it takes priority. The consequence: adding public/ads.txt to the repo does nothing, because that file is never served.

I tripped on the query string. A route only matches a URL with a query when the pattern ends in *. curl -sI https://nguyentrungkhuong.com/ads.txt returns 200, while /ads.txt?x=1 falls through to the /* route, where no file has that name, and comes back 404. Add ?nocache=1 to dodge the cache during a manual check and you will think the file is gone.

Deploy and rollback without a server

.github/workflows/deploy.yml now has two jobs, both on ubuntu-latest. The build job runs npm run build:static and uploads out/ as an artifact. The publish job downloads the artifact, copies the 404 page, then:

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 compares the hash of each file and uploads only the ones that changed. The deploy on 24 Oct 2026 read 265 files, uploaded 31, skipped 213 that were already there, and took 1.48 seconds. The whole workflow, from push to a green smoke test, takes about 94–98 seconds, against 106–149 seconds for the self-hosted runner deploys in July and August 2026. Speed was not the reason for the move; most of the time still goes to npm ci and next build.

Rollback needs no rebuild. Every deploy is a version, and the rollback.yml workflow calls:

bash
npx --yes wrangler@4.144.0 deployments list
npx --yes wrangler@4.144.0 rollback <version-id> --message "rollback from GitHub Actions" --yes

The releases/ folder with its current symlink on the server is no longer needed. The domain's proxied DNS record stays as it was, so in the worst case, removing the nguyentrungkhuong.com/* route in the dashboard sends traffic back to the old origin.

The trade-off of handing everything to Cloudflare

The site now depends entirely on one provider, and there are no more nginx access logs, only Cloudflare analytics. Any feature that needs server-side code (a self-hosted form handler, an API, SSR) turns requests into counted requests, and going over the limit means error 1027 for the whole site. The contact form and the newsletter still call Web3Forms and Mailchimp straight from the browser, so they do not touch this limit yet. If I add anything that runs on the server, I will put it in a separate Worker on a separate route, so the main site stays pure assets.

Related posts

all posts →