Operator reference · the nginx exit

Hosting static sites on Cloudflare Workers

Cloudflare can host your website's files and run backend code without a server for you to maintain. Start here with what Workers, Wrangler, R2 and D1 do, then follow the configuration and migration runbook. The examples come from twelve production sites moved from nginx on 2026-09-27 and 28, from a single-page portfolio to a 27,900-file archive with about 4,300 legacy redirects. This page is served by the setup it describes.

Start here / The mental model

What are we hosting, and which Cloudflare service does what?

A static site sends files that already exist: HTML pages, CSS, browser JavaScript and images. It can still have interactive menus, search and calculators in the browser. A dynamic backend runs code when a request arrives, for example to validate a contact form or look up a customer's records. You can host the static part first and add a backend only where it is needed.

1. Your computer or CI

Write the site and prepare its publish folder, locally or in CI (continuous integration, an automated build runner). Here, dist/ means the finished files to upload. A plain HTML site can use an existing folder; Astro or Eleventy generates one with a build command.

2. Wrangler uploads it

Your deployment tool reads wrangler.jsonc, uploads the site's assets and any Worker script, and configures their connections. Your computer does not need to stay on after deployment.

3. Cloudflare serves visitors

A browser requests /about.html; Cloudflare serves the uploaded file. A request to /api/contact can run a Worker script that validates the submission and saves a row in D1.

The upload and delivery model uses Workers static assets. Product descriptions below follow Cloudflare's documentation as of Sep 2026.

Workers vs R2 vs D1: code, files and records

These services do different jobs and can work together. A brochure site needs only static assets. A site with contact forms adds Worker code and perhaps D1. A site with large downloads or user uploads can add R2.

Choose by what you need to run or store
Tool or serviceWhat it doesConcrete useBoundary / when to skip it
WorkersRuns backend code on Cloudflare's managed platform. Serverless means Cloudflare manages the servers; you supply the code.Validate POST /api/contact, query a database, or generate an HTML response.A Worker script is not a persistent disk or a SQL database. Skip custom code when uploaded files and declarative rules suffice.
Workers static assetsStores and serves the website files uploaded with a Worker deployment.dist/index.html, site.css and logo.svg.No script is required for an assets-only site. Files change through deployment; use separate storage for runtime uploads.
R2Object storage: files stored in buckets under keys, similar to Amazon S3.A PDF at reports/2026-09.pdf, videos, backups or user attachments.R2 stores bytes; it does not execute your backend or provide relational SQL queries. Ordinary site assets can stay in the deployment.
D1A managed SQL database with SQLite semantics: tables, rows, indexes and queries.An inquiries table with email, message and creation time; query submissions by date.Use it for structured records. Put large attachments in R2 and their object keys in D1. A read-only static site may need no database.
Workers KVA distributed key-value store: retrieve a value by its key.site:banner → Maintenance Sunday; cached API results or read-heavy configuration.Updates are eventually consistent: other locations can see an older value. Avoid it for balances or decisions requiring immediate agreement.
WranglerThe command-line tool on your computer or CI that develops, configures and deploys these resources.npx wrangler dev starts a development server; Wrangler can also upload versions and stream logs.It is a tool you run, rather than a hosting or storage service. Visitors reach Cloudflare, not Wrangler on your laptop.

What does Wrangler actually do?

wrangler.jsonc is its project configuration: the deployment name, asset folder, optional script and connected resources. JSONC allows comments. npx runs the Wrangler CLI through Node.js tooling.

  • npx wrangler dev: test locally before uploading.
  • npx wrangler versions upload: upload a candidate version for the preview-and-promote workflow.
  • npx wrangler deploy: upload and activate a deployment. This changes the live site; the runbook below uses a guarded script.
  • npx wrangler tail: watch live Worker script logs while troubleshooting.

Wrangler can bundle Worker code, but your site's build tool still prepares the HTML and other assets. Wrangler overview.

How does a Worker reach storage?

A binding is a configured connection exposed to your script by name. For example, env.DB can refer to a D1 database, env.UPLOADS to an R2 bucket, and env.ASSETS to deployed site files. The name is chosen in configuration; it does not automatically create the database or bucket.

For a form with an attachment: the browser submits to a Worker, the Worker validates it, stores the file in R2, and saves the inquiry plus the file's key in D1. The browser never needs direct database access.

Bindings connect code to resources. The forms section shows a backend in practice.

Where do Pages, DNS and the CDN fit?

Pages is Cloudflare's separate website deployment product; compare it with Workers in the decision strip. DNS connects a hostname such as example.com to its destination. A CDN caches and delivers content near visitors. Putting Cloudflare in front of nginx uses its proxy/CDN; uploading files to Workers static assets moves the hosting itself to Cloudflare.

Start with the smallest setup: static assets for pages; a Worker script for request-time behavior; D1 for queryable records; R2 for separately managed files. Now the limits, configuration keys and migration steps below have a place to fit.

00 / Quick reference

Cloudflare Workers static assets: limits and decisions

Limits card · developers.cloudflare.com, as of Sep 2026
LimitValueNote
Files per version20,000 Free
100,000 Paid
Paid figure needs wrangler ≥ 4.34.0. Every file in dist/ counts; move bulk media to R2.
Max file size25 MiBBoth plans. Larger files go to R2.
_redirects2,000 static
+ 100 dynamic
1,000 chars per rule. Default status 302.
_headers100 rules2,000 chars per line. Asset responses only.
run_worker_first100 patternsMust start / or !/; negations win; order is irrelevant.
Free requests100,000/dayCounts script invocations only. Asset-only requests are free and unlimited.
Workers Paid$5/mo min10M requests/mo included, then $0.30/M. No egress fees.
Routes / custom domains1,000 / 100Per zone.
Deployable versionslast 100Applies to versions deploy and rollback.
Version (preview) URLsnoindexAliased URLs sent X-Robots-Tag: noindex (observed 2026-09-28). Alias + name ≤ 63 chars.
Single Redirects10 Free25 Pro, 50 Business. Regex needs Business.
D1 free5M reads/day
100k writes/day
5 GB total, 10 databases, 500 MB each. Resets 00:00 UTC.
R2 free10 GB-month1M Class A, 10M Class B ops/mo. Egress free.

Decision strip

  • assets-only vs script WorkerUse assets-only (no main) when every rule fits in _redirects and _headers. Add a script when you need 410/403, more than 2,100 redirects, regex precedence, or server-rendered pages. 10 of the 12 fleet hosts are assets-only.
  • route vs custom domainUse a route (host/* over the existing proxied record) to migrate: zero DNS change, and rollback is deleting the route. Use a custom domain for a new host with no record yet; it creates the DNS record and certificate but fails on a hostname that already has a CNAME.
  • html_handling by canonical URL shapeCanonical /a/ or /a: auto-trailing-slash (default). Always /a/: force-trailing-slash. Always /a: drop-trailing-slash. Canonical /a.html: none, plus a / /index.html 200-style rewrite for /.
  • _redirects vs zone rule vs Worker codeHost-level (www, alias domains): zone Single Redirect. Path-level under 2,000 static lines: _redirects. Over the caps, regex, or 410: Worker code, with run_worker_first globs for just those paths.
  • Workers vs Pages (new static site)Workers. Cloudflare's Pages docs say "Start new projects with Workers" (Aug 2026); _redirects and _headers work natively. Pages keeps two edges: custom domains on zones outside Cloudflare, and custom branch aliases ("coming soon" on Workers).
  • stay on the originExisting applications that depend on PHP/MySQL, persistent local files or server logs do not move by uploading their files: WordPress, MediaWiki, a helpdesk, a log-reading dashboard. Porting their backend is a separate project; Workers can support dynamic apps with services such as D1 and R2. See 07.

01 / How a request flows

How does a request reach a Workers static asset?

Ten hops, in order. Most migration bugs are a rule sitting at the wrong hop: a Page Rule that is no longer consulted, a header rule that never sees a Worker-built response, a trailing-slash redirect answered by html_handling before your 301 line could run (because the line was never written).

  1. Proxied DNS record.

    The existing orange-cloud A/CNAME stays exactly as it was. A route needs a proxied record on an active zone; the origin IP behind it stops mattering once the route exists.

  2. Zone rules.

    Single Redirects run in the first request phase (http_request_dynamic_redirect), then URL rewrites, config and origin rules, WAF custom rules, rate limiting, managed rules, Super Bot Fight Mode, Bulk Redirects, header transforms, Cache Rules. A matching Single Redirect answers before any Worker runs.

    Bot Fight Mode can challenge your own CI runners and parity probes (403 with cf-mitigated).

  3. Page Rules.

    For requests served by a Worker route, a Forwarding URL Page Rule is ignored (Cloudflare's "Workers with Page Rules" table). Always Use HTTPS is still respected; Browser Cache TTL and Rocket Loader are ignored. A forwarding Page Rule on a host silently stops firing at cutover.

  4. Route match.

    Most specific pattern wins: walletrecovery.info/api/* beats walletrecovery.info/*. * is the only operator, paths are case-sensitive, and a route with no Worker punches a hole in a broader one.

  5. Worker script, if the path is in run_worker_first.

    Matching globs invoke your script (billed); everything else goes straight to assets (free). Without run_worker_first, the script runs only when no asset matched.

    From compatibility date 2025-04-01, browser navigations (Sec-Fetch-Mode: navigate) that miss get not_found_handling instead of the script whenever it is set.

  6. _redirects.

    Evaluated before asset matching, so a redirect fires even when a file exists at the source path. Static lines are checked, then dynamic (splats, placeholders). First match wins; 200 lines rewrite without chaining.

  7. Asset lookup and html_handling.

    Finds the file, and normalizes non-canonical forms with 307. A path whose percent-encoding is non-canonical is also 307'd to the canonical encoding.

  8. not_found_handling.

    404-page serves the nearest 404.html walking up the tree with status 404; single-page-application serves /index.html with 200; none (the default) hands off to the script if there is one, else a bare 404.

  9. _headers.

    Attached to asset responses, including those fetched by your script through env.ASSETS.fetch(). Not applied to responses your script builds itself; set those headers in code.

  10. Response, and what it can reach.

    A script can call other Workers through service bindings (no extra request charge, and the target needs no route at all), D1, KV and R2. Assets are cached at the edge with tiered cache and default to Cache-Control: public, max-age=0, must-revalidate plus an ETag.

Request tracer · 9 real requests, as curl saw them on 2026-09-28

      acted passed through would have acted, did not not reached
      All samples (static trace; bold = decided, struck = bypassed)
      RequestHops passedResultWhy
      GET https://www.vellum.capital/DNS → Zone rules301 https://vellum.capital text/htmlZone Single Redirect: www host to apex, 301. Alias hosts never reach a Worker: Single Redirects run in the first request phase, so the www record needs no route. The fleet's rule builder appends the request path and keeps the query string.
      GET https://vellum.capital/whitepaperDNS → Zone rules → Page Rules302 https://vellum.capital/wp-content/uploads/sites/37/2021/03/Bitcoins-Rise-and-the-Coming-Crypto-Economy-John-Schroder-Vellum-Capital-March-2021.pdf text/htmlSingle Redirect, recreated on 2026-09-27 after the cutover lost it: 302 to the PDF. Cloudflare ignores Forwarding URL Page Rules for requests a Worker route serves. The rule fired the day before cutover and stopped the minute the route went live. Convert every forwarding Page Rule to a Single Redirect before adding the route.
      GET https://walletrecovery.info/api/contact.php?health=1DNS → Zone rules → Route match → Worker script200 application/jsonForms Worker answers the health probe: D1 inquiry log writable. A second route on the same host hands /api/* to a separate forms Worker. The legacy .php URL survives because the Worker's own path table keeps it; the HTML never had to change.
      GET https://walletrecovery.info/2018/01/08/how-to-recover-your-corrupt-or-deleted-bitcoin-core-wallet/DNS → Zone rules → Route match → Worker script → _redirects301 /articles/how-to-recover-your-corrupt-or-deleted-bitcoin-core-wallet/Static line generated from the nginx map: 301 to /articles/…/. _redirects runs before the asset lookup, so a WordPress permalink with no file behind it still redirects. The Location is relative, and 301 is written on the line because the default is 302.
      GET https://vellum.capital/feed/DNS → Zone rules → Route match → Worker script → _redirects → Asset lookup + html_handling → _headers (asset responses only)200 application/xml/feed/ /feed/index.xml 200 (a rewrite: URL unchanged). nginx's index index.xml has no config equivalent: html_handling only knows index.html. A 200 line in _redirects serves the file under the old URL.
      GET https://whopaysforai.org/aboutDNS → Zone rules → Route match → Worker script → _redirects → Asset lookup + html_handling → _headers (asset responses only)200 text/html/about /about.html 200, one generated line per page. Ports nginx try_files $uri.html. With html_handling none nothing is inferred, so the build emits one rewrite per page and fails if the count would pass 2,000. /about/ is a 404 here, exactly as on nginx.
      GET https://cheatsheets.davidveksler.com/software-devopsDNS → Zone rules → Route match → Worker script → _redirects → Asset lookup + html_handling200 text/htmlPath is in run_worker_first. Script fetches the prerendered /_x/hub/software-devops.html through env.ASSETS. A PHP category page became a build-time prerender. The script runs only for the ~42 listed globs; every other path is an asset request that never invokes (or bills) the script. The Worker sets Cache-Control and the security headers itself.
      GET https://walletrecovery.info/faq.htmlDNS → Zone rules → Route match → Worker script → _redirects → Asset lookup + html_handling307 /faq/auto-trailing-slash: /faq.html is not canonical. 307 to /faq/. html_handling normalizes with 307 Temporary Redirect, never 301. Harmless for internal links; wrong for URLs with backlinks. Write legacy variants as explicit 301 lines in _redirects.
      GET https://walletrecovery.info/nope-xyz/DNS → Zone rules → Route match → Worker script → _redirects → Asset lookup + html_handling → not_found_handling → _headers (asset responses only)404 text/html404-page: serves the nearest 404.html up the tree, status 404. not_found_handling defaults to none, which returns a bare 404 with no HTML page. Set 404-page and ship a 404.html at the root.

      02 / wrangler.jsonc, key by key

      Which wrangler.jsonc keys does a static site need?

      Values are what the fleet runs on 2026-09-28. The account id is pinned in each repo's wrangler.jsonc; ids never appear on this page.

      KeyFleet valueWhyGotcha
      namehost-with-dashesOne Worker per host; the name appears in every version URL.Alias + name must fit 63 chars, so long names shrink your alias budget.
      account_idpinnedDeploy tokens that see several accounts otherwise make wrangler prompt or guess.It is not a secret, but keep it off public pages and screenshots anyway.
      compatibility_date2026-09-27 / -28Freezes runtime behavior at the migration date.≥ 2025-04-01 makes navigations prefer assets over the script when not_found_handling is set.
      mainabsent, or workers/site/index.jsAbsent = assets-only Worker: no script, no request billing.Adding main never intercepts a path that matches an asset; only run_worker_first does.
      assets.directory./dist, ./dist/staticThe build output, uploaded by content hash: unchanged files are not re-sent.A directory named _redirects/ or _headers/ inside it crashes wrangler with EISDIR. Exclude files with .assetsignore (gitignore syntax).
      assets.html_handlingauto-trailing-slash (9 hosts), none (3)Must match your canonical URLs, or every internal link costs a redirect.Normalizing redirects are 307, not 301. With none, / no longer finds index.html: add a rewrite or handle it in the script.
      assets.not_found_handling404-page (all 12)Serves the nearest 404.html with status 404, like nginx error_page 404.Default none gives a bare 404 with no page. single-page-application turns every typo into a 200 soft-404.
      assets.bindingASSETS (script Workers only)Lets the script read assets with env.ASSETS.fetch().Only the pathname matters; the hostname in the fetched URL is ignored.
      assets.run_worker_first~42 globs (CheatSheets), 80-glob budget (freecapitalists.org)Only these paths invoke (and bill) the script.Hard cap 100 patterns. true sends every request through the script; on Free, over-quota script paths return 429 while !/-excluded paths still serve.
      workers_devfalseNo public *.workers.dev copy of production to be indexed or to bypass zone rules.Defaults to true; inferred false only when routes is present, so set it before the first bootstrap deploy.
      preview_urlstrueEvery versions upload gets a URL to run parity against.If omitted it follows workers_dev, which is false here: set it explicitly.
      routes{"pattern": "host/*", "zone_name": "zone"}The cutover switch. Commented out until cutover day.zone_name is the zone, not the host: aisafety.davidveksler.com/* uses zone davidveksler.com. Applied by triggers deploy, not versions deploy.
      observability{"enabled": true} on script WorkersWorkers Logs for scripts and forms Workers.Assets-only sites leave it unset: there is no script to log.
      services{"binding": "FORMS", "service": "…-forms"}The site script calls the forms Worker in-process; the forms Worker needs no route.Each call counts toward the subrequest limit; max 32 Worker invocations per request.
      d1_databases{"binding": "DB", …} (forms Workers)Inquiry log, event rows, rate-limit ledger.Create the database and apply schema.sql with --remote before the first deploy.
      vars vs secretsmail domain and from-address in varsvars are committed config; keys (TURNSTILE_SECRET, mail API key, HMAC secret, IP hash salt) are secrets.After a versions upload leaves a newer undeployed version, wrangler secret put refuses; use wrangler versions secret put.
      wrangler.jsonc · assets-only site
      {
        "$schema": "node_modules/wrangler/config-schema.json",
        "name": "coloradofirearmswatch-org",
        "compatibility_date": "2026-09-27",
        "account_id": "<ACCOUNT_ID>",   // pin it
        "assets": {
          "directory": "./dist",
          "html_handling": "auto-trailing-slash", // canonical /a/
          "not_found_handling": "404-page"    // ship dist/404.html
        },
        "workers_dev": false,   // no public *.workers.dev copy
        "preview_urls": true,   // parity target per upload
        // Uncomment on cutover day, commit, deploy --full-parity.
        "routes": [
          { "pattern": "coloradofirearmswatch.org/*",
            "zone_name": "coloradofirearmswatch.org" }
        ]
      }
      wrangler.jsonc · script Worker (CheatSheets)
      {
        "name": "cheatsheets-davidveksler-com",
        "main": "workers/site/index.js",
        "compatibility_date": "2026-09-28",
        "account_id": "<ACCOUNT_ID>",
        "assets": {
          "directory": "./dist",
          "binding": "ASSETS",
          "html_handling": "none",        // canonical URLs end .html
          "not_found_handling": "404-page",
          // generated by the build; 42 entries, cap is 100
          "run_worker_first": [
            "/", "/*.php", "/_x/*", "/404.html",
            "/popularity", "/sitemap.xml", "/subscribe",
            "/confirm", "/software-devops", "/software-devops/"
            // ...one pair per category hub
          ]
        },
        "services": [   // forms Worker has no route of its own
          { "binding": "FORMS",
            "service": "cheatsheets-davidveksler-com-forms" }
        ],
        "observability": { "enabled": true },
        "workers_dev": false,
        "preview_urls": true,
        "routes": [
          { "pattern": "cheatsheets.davidveksler.com/*",
            "zone_name": "davidveksler.com" }  // the zone
        ]
      }
      Vite plugin collision

      The Cloudflare Vite plugin auto-loads any root wrangler.jsonc into the app build. whopaysforai.org, a vinext app deployed as a static snapshot, names its config wrangler.static.jsonc and runs every wrangler command with --config wrangler.static.jsonc.

      03 / Porting nginx

      How do you migrate nginx rules to Cloudflare Workers?

      Everything nginx did implicitly has to be written down. The most dangerous losses are the silent ones: server-wide headers, slashless variants a regex used to catch, and status codes an assets-only Worker cannot send.

      nginx constructWhat it didWorkers static-assets equivalentGotcha / cannot be ported
      return 301 in a www/alias serverHost redirectZone Single Redirect, target concat("https://apex", http.request.uri.path), preserve query on_redirects cannot match a hostname. Query preservation is off by default in Single Redirects.
      map $uri $redirect_uriExact-path redirect tableGenerated static _redirects lines, one per entry, 301 written explicitlyBuild must fail over 2,000 static lines, not truncate. Default status is 302.
      location = /xExact matchOne static _redirects line, or a run_worker_first entry if it ran codeExact means exact: /x/ is a different line.
      location ^~ /old/Longest-prefix match, stops regex search/old/* /new/:splat 301A splat is dynamic (100 cap). _redirects is first-match top-down, not longest-prefix: sort longer prefixes first.
      location ~ regexRegex, first match in file orderEnumerate into static lines, or a script Worker that reproduces nginx precedenceNo regex in _redirects. freecapitalists.org's ~4,300 rules run in its site Worker: exact, then longest prefix, then regex in file order, rendered from the same rule list as the old nginx conf.
      try_files $uri $uri.htmlServe /a from a.html, and /a.html tooauto-trailing-slash serves /a from a.html; or none + /a /a.html 200 per pageUnder auto-trailing-slash, /a.html becomes a 307. Keep both 200s with none plus generated rewrites (whopaysforai.org).
      index index.xmlFeed served at /feed//feed/ /feed/index.xml 200html_handling only knows index.html. Also add /feed /feed/ 301.
      rewrite … lastInternal rewrite_redirects line with status 200Relative targets only, first rule only, no chaining. No 404-status rewrites.
      add_header / more_set_headers server-wideX-Frame-Options, nosniff, Referrer-Policy on every response/* block in _headers, plus the same headers set in script codeInherited from nginx.conf, so invisible in the site's vhost. Six fleet sites shipped without them.
      add_header Access-Control-Allow-Origin *CORS on public JSON/*.json rule in _headersOnly for asset responses; a script-served JSON needs it in code.
      expires / Cache-ControlBrowser cache lifetime_headers rule, e.g. /*.html → max-age=1800Default is max-age=0, must-revalidate + ETag. Long max-age only on fingerprinted files.
      gzip / brotliCompressionAutomaticDelete the config. Nothing to port.
      error_page 404 /404.htmlCustom 404 pagenot_found_handling: "404-page" + dist/404.htmlNearest 404.html up the tree wins, so a section can have its own.
      return 410 / deny allGone / ForbiddenScript Worker onlyAssets-only cannot send 410 or 403; those paths become 404. Accept it or add a script.
      default_type / types {}Content-Type for odd files_headers Content-Type rule per pathExtensionless files (/LICENSE, /.well-known/traffic-advice) need explicit types.
      PHP includes, fastcgi_passServer-rendered pagesPrerender at build; serve through run_worker_first if a path needs logicThe CheatSheets Explorer and hubs are prerendered .php output under /_x/.
      access_logPer-request log with referrerCloudflare Analytics GraphQL for counts; D1 rows for form and beacon eventsPer-request referrers are gone. CheatSheets deleted its referrer report.
      Non-ASCII pathsnginx $uri is decodedPercent-encode sources in generated linesWorkers match the encoded path. An emoji slug written raw never matches.
      Slashless variants (^/x/?$)One regex caught /x and /x/Two lines: /x/ /y/ 301 and /x /x/ 301 (or straight to /y/)Unwritten, /x 404s (no asset) or gets a 307 (asset exists). Live: walletrecovery.info/about-david is a 404; only the slash form was mapped.
      Digit-only regex (^/[0-9]{4}/?$)Year/month archivesEnumerate: vellum emits every /YYYY and /YYYY/MM (2010 to 2030, with and without slash) as static lines:placeholders cannot be constrained to digits. In run_worker_first, freecapitalists.org uses ten globs, /0* to /9*.
      Splat orderingn/a in nginxStatic lines first, splats lastCloudflare: "static redirects should appear before dynamic". The fleet budgets every line after the first splat against the 100 dynamic cap.

      Worked example: WalletRecovery's nginx map to _redirects

      Six real lines from deploy/redirects.map (53 active rules), plus the one non-ASCII source in the map. The converter parses each source target; line, percent-encodes the source with Python's quote(source, safe="/-._~!$&'()*+,;=:@%"), writes 301, and exits 1 above 2,000 lines.

      deploy/redirects.map (nginx)
      map $uri $redirect_uri {
          default "";
          /about-david/                          /about/;
          /blog/                                 /articles/;
          /connect/                              /contact/;
          /contact-us/                           /contact/;
          /data-recovery-shipment-instructions/  /shipping/;
          /frequently-asked-questions/           /faq/;
          /2022/11/18/how-i-recover-stolen-nfts-from-crypto-scammers-🥷🏼/  /articles/how-i-recover-stolen-nfts-from-crypto-scammers/;
      }
      # server block: if ($redirect_uri) { return 301 $redirect_uri; }
      dist/_redirects (generated)
      # hand-written rules first; first match wins
      /tools/:tool/offline.html /tools/:tool/offline 200
      # generated from deploy/redirects.map
      /about-david/ /about/ 301
      /blog/ /articles/ 301
      /connect/ /contact/ 301
      /contact-us/ /contact/ 301
      /data-recovery-shipment-instructions/ /shipping/ 301
      /frequently-asked-questions/ /faq/ 301
      /2022/11/18/how-i-recover-stolen-nfts-from-crypto-scammers-%F0%9F%A5%B7%F0%9F%8F%BC/ /articles/how-i-recover-stolen-nfts-from-crypto-scammers/ 301

      Result, curl on 2026-09-28: the encoded emoji URL answers 301 /articles/how-i-recover-stolen-nfts-from-crypto-scammers/. The hand-written first line exists because auto-trailing-slash would 307 the saved offline.html tool copies that nginx served with 200.

      04 / The guarded deploy pipeline

      How do you deploy with wrangler versions upload and a preview URL?

      Upload a version without serving it, prove it matches production byte for byte, stop for a human, then promote. Every fleet repo runs a copy of one template, scripts/deploy-cloudflare.sh plus a PowerShell twin.

      1. 01 preflightClean tree; Node ≥ 22; npm ci if wrangler missing; token from CLOUDFLARE_API_TOKEN (CI) or a local env file; parity script present; routed? parsed from wrangler.jsonc.
      2. 02 build + gatesSite build and its lint/SEO gates. Fails if the build modified any tracked file.
      3. 03 uploadwrangler versions upload --preview-alias c<sha> --tag <sha>. A brand-new Worker is bootstrapped with wrangler deploy first (unrouted only).
      4. 04 paritycf_parity.py: preview vs production. Smoke by default; full when unrouted or --full-parity.
      5. 05 stop?Unrouted or --preview-only: exit 0, "preview ready, production unchanged".
      6. 06
        human
        confirm
        [y/N]
      7. 07 promotewrangler versions deploy <id>@100% --yes, then wrangler triggers deploy (routes), then a host cache purge that only warns on failure.
      8. 08 live verifyLoop until: distinctive string present (here-string grep), prod body of the verify path hashes equal to the preview's, and wrangler deployments status output contains the version id.

      Step 08 checks that the version id appears in deployments status; it does not parse the percentage. The loop has a fixed ceiling, but a correct verifier passes on the first iteration: a slow pass means a broken check, not "propagation" (see incident 2026-09-28, SIGPIPE).

      Flags · bash / PowerShell twin
      bashPowerShellEffect
      --yes, -y-YesSkip the confirm prompt (the only way CI promotes).
      --preview-only-PreviewOnlyStop after parity; production untouched. Also the automatic behavior before cutover.
      --full-parity-FullParityFull mode: sitemaps, origin file list, variants, 11 headers. Mandatory for the cutover deploy.
      --skip-parity-SkipParityNo comparison. For a site whose production is known-broken; leaves a gap in the audit trail.
      Why the PowerShell twin shells out

      deploy-cloudflare.ps1 maps its switches onto the bash flags and runs Git for Windows' bash.exe from Program Files, not whatever bash is on PATH: that can resolve to WSL, whose Node may be older than 22.

      Parity: what "matches production" means

      Compared per path

      Status; Location (origin-normalized); base Content-Type (charset stripped); SHA-256 of the normalized body; 11 headers: cache-control, link, x-robots-tag, content-language, access-control-allow-origin, content-disposition, x-frame-options, x-content-type-options, referrer-policy, content-security-policy, strict-transport-security.

      Probe sources

      Both sitemaps (found via robots.txt Sitemap: lines plus /sitemap.xml, /sitemap-index.xml, /sitemap_index.xml, recursing indexes); the origin's live file list; /; /robots.txt; one random-string 404 probe.

      URL variants

      In full mode, 40 random sitemap pages (--variant-sample) are also probed as slash, slashless, .html and /index.html forms. This is where 307-vs-301 and slashless 404s surface.

      Normalizers (every Cloudflare zone needs these)

      Before hashing HTML: strip the challenge-detection script, /cdn-cgi/ scripts, the Web Analytics beacon, Cloudflare Fonts @font-face blocks and Google Fonts links; collapse email obfuscation to EMAIL; undo Automatic HTTPS Rewrites; drop inter-tag whitespace (an injected tag leaves its newline behind), then collapse whitespace runs.

      Allow file

      One line per explained difference: <path-regex> <field> # reason, field is status, location, content-type, body, header:<name>, missing or *. A 5-rule kit default loads first; per-site files layer on top. Example: ^/whitepaper$ * # zone rule answers this.

      Exit codes and trust

      0 no unexplained differences, 1 differences (JSON report path printed), 2 could not run. A run with more than max(5, 10%) challenged responses exits 2: "too many challenged responses to trust this run".

      Smoke vs full

      Smoke (routine deploys): candidate sitemap paths, no URL variants, no origin file list. Full (first deploy, cutover, --full-parity): everything above. freecapitalists.org's cutover run passed on 33,577 paths.

      Baseline parity

      When the old origin runs an older commit than the repo, parity against HEAD reports content drift as regressions. Check out the origin's commit in a git worktree, build it, upload with --preview-alias l<sha>, and run full parity against that preview first.

      What parity cannot see

      Zone behavior in front of the route (Page Rules, Single Redirects) is identical for both sides until cutover, so parity passes while a Page Rule is about to die. Check forwarding rules by hand.

      No Git-connected builds

      Workers Builds can deploy on every push. The fleet deliberately does not connect it: pushing to GitHub is automatic, but making anything live is a human decision at step 06. The one exception is a scheduled data-only publish with its own guard (08).

      05 / Cutover, soak, rollback, decommission

      How do you cut over from nginx to Workers with zero DNS change?

      Pre-cutover checklist

      • Full parity passes against production, with every allow-file line explained.
      • Security headers present: _headers /* block and in script responses.
      • dist/404.html exists and not_found_handling is 404-page.
      • Every forwarding Page Rule on the host recreated as a Single Redirect, then the Page Rule deleted.
      • Forms Worker deployed unrouted and tested on its version URL: health returns ok, one real submission arrives.
      • Secrets set: bootstrap wrangler deploy first, then secret put; versions secret put once a newer version is upload-only.
      • .gitattributes has * text=auto eol=lf so Windows and Linux builds ship identical bytes.
      • TZ=UTC set in the build for generators that print dates.
      • www and alias domains have zone Single Redirects (301, path kept, query preserved).
      • Analytics replacement ready: Cloudflare Analytics query and D1 event rows.
      • Zone HSTS checked as a header value, not a toggle (enabled with max_age 0 sends no policy).
      • Origin copy frozen: no further origin deploys after cutover, so rollback lands on a known state.

      Cutover

      Uncomment routes in wrangler.jsonc, commit, run scripts/deploy-cloudflare.sh --full-parity. triggers deploy creates the route; the proxied DNS record is untouched. Traffic moves at the edge within the deploy.

      cutover
      git commit -am "Cut over to Workers: enable route"
      scripts/deploy-cloudflare.sh --full-parity

      Soak: 7 days

      The origin keeps its copy, untouched. soak_check.py runs full parity with the origin as "production" by pinning the TCP connection to the origin's address while sending the public hostname in SNI and Host, since DNS now resolves to Cloudflare. It also checks that / no longer carries the origin's Last-Modified (proof the route is active), a random path 404s with HTML, aliases 301, and form health endpoints answer.

      Rollback: two levers
      LeverCommandUse whenResult
      Previous versionnpx wrangler rollback [version-id]A deploy shipped bad content; the platform is fine.New deployment of the prior (or named) version on all routes, immediately. Last 100 versions only; blocked if a bound R2/KV/queue was deleted.
      Back to originRemove routes, then npx wrangler triggers deployThe Worker path itself is wrong: routing, a platform limit, an incident you cannot fix forward.The proxied record serves the origin again at once. The origin serves its last origin deploy, which is stale if you kept publishing to Workers.
      Decommission, after the soak and an explicit go-ahead
      1. Confirm the soak window closed with clean soak_check and fleet_check runs.
      2. Snapshot the origin vhost config and any server-side files into git (commits are the backup).
      3. Remove the vhost and its htdocs from the origin; reload nginx.
      4. Delete or disable the old origin deploy script so nobody "deploys" to a server that no longer serves the site.
      5. Remove origin-only DNS records that exposed the server for that host, if any.
      6. Update the runbook and site list: the host's only rollback is now wrangler rollback.

      06 / Forms and beacons without a server

      How do you replace a PHP contact form with a Worker, D1 and Turnstile?

      A contact form is the last reason most static sites keep PHP. The kit's contact-Worker template handles one POST in this order; each step is cheap and the expensive calls come last.

      1. Route and method.

        GET ?health=1 returns JSON booleans for D1, Turnstile secret and mail config: 200 when all present, 503 otherwise. Any other non-POST: 405.

      2. Config check, fail closed.

        Missing DB, TURNSTILE_SECRET or mail config: 500 with the "not configured" message. A half-configured form never silently eats messages.

      3. Honeypot.

        A hidden field with any value returns the normal success response. No send, no log, no signal to the bot.

      4. Rate limit (D1, fixed window).

        Key: SHA-256 of salt|cf-connecting-ip, truncated to 12 bytes. One row per hit in hits; 2% of calls prune rows older than 24 h. Fails open on a D1 error: a database blip must not block real inquiries. WalletRecovery: 5 per 900 s. Exceeded: 429.

      5. Field validation.

        Required fields enforced (422); each field trimmed, length-capped and pattern-checked per the site config.

      6. Turnstile siteverify.

        POST https://challenges.cloudflare.com/turnstile/v0/siteverify with secret, response and remoteip = cf-connecting-ip. Tokens are single-use and expire after 300 s.

      7. Mail send.

        Mailgun or Resend, chosen per site. A provider failure returns 502.

      8. D1 log row, after the send.

        INSERT INTO inquiries (ts, data) with attribution fields only, so the count means "inquiries delivered". A logging failure is swallowed: the email already went out.

      9. Response.

        fetch() callers (X-Requested-With: fetch or Accept: application/json) get JSON {ok, error}. Plain form posts get 303 to the success or failure page, so the form works with JavaScript off.

      The template's test suite runs 13 assertions under wrangler dev: health, 405, unknown path 404, honeypot success, missing field 422, bad email 422, missing token 422, send reached (502 against a stub), no-JS 303, 429 after 5 posts, beacon 204, beacon health, event row stored. Beacons (handleEvent) always answer 204 whatever happens, with their own rate cap (default 120 per window).

      schema.sql · kit template (no PII in logged rows)
      CREATE TABLE IF NOT EXISTS inquiries (
        id   INTEGER PRIMARY KEY,
        ts   INTEGER NOT NULL,   -- unix seconds, UTC
        data TEXT    NOT NULL    -- JSON from SITE.logRow(): attribution only
      );
      CREATE INDEX IF NOT EXISTS inquiries_ts ON inquiries (ts);
      
      CREATE TABLE IF NOT EXISTS events (
        id   INTEGER PRIMARY KEY,
        ts   INTEGER NOT NULL,
        name TEXT    NOT NULL,
        data TEXT    NOT NULL
      );
      CREATE INDEX IF NOT EXISTS events_name_ts ON events (name, ts);
      
      CREATE TABLE IF NOT EXISTS hits (       -- rate-limit ledger
        ts      INTEGER NOT NULL,
        ip_hash TEXT    NOT NULL,          -- salted, truncated
        kind    TEXT    NOT NULL           -- 'contact' | 'event'
      );
      CREATE INDEX IF NOT EXISTS hits_lookup ON hits (ip_hash, kind, ts);
      Deploy-order rule

      A forms Worker on its own route (host/api/*) must be live before the HTML that posts to it, or submissions 404 in the gap. A forms Worker reached by service binding, answering both the old and new paths, removes the ordering constraint.

      Forms Workers skip the gate

      Forms Workers deploy with a plain wrangler deploy: live immediately, no preview, no parity, no promote step. Unrouted ones get an extra versions upload --preview-alias preview purely to probe. Test on the version URL, then deploy deliberately.

      Variants in the fleet
      WorkerReached byStorageAnti-abuseDistinctive detail
      WalletRecovery formsRoute walletrecovery.info/api/*D1 (template)Honeypot, 5/900 s, TurnstileKeeps legacy /api/contact.php and /api/tool_event.php paths. Beacon body capped at 200 bytes; tool and state must match ^[a-z0-9-]{1,40}$ or the row is dropped. Mailgun.
      CheatSheets newsletterService binding FORMS, no routeD1: subscribers, confirmed, hits10/3600 sDouble opt-in. Token = base64url(email).timestamp + HMAC-SHA256; 7-day TTL; constant-time compare. Resend.
      Vellum investor inquiryRoute on /api/investor-inquiryKV audit trail, key inquiry:<iso-ts>:<uuid>Honeypot, Host check, Origin check. No rate limit, no Turnstile.Hand-built, not the template. Writes KV first, mails via ctx.waitUntil() so a mail outage cannot lose an inquiry. Host check blocks the version-URL bypass.
      freecapitalists contactRoute /api/contact*n/aHoneypot website, TurnstileTurnstile was added after the honeypot alone let through link spam in Aug 2026. Field caps: name 200, email 200, message 5,000. JSON or a small HTML page by Accept.

      07 / What stays off Workers

      Which sites should stay on the origin server?

      WorkloadWhy it staysWhat moving would takeVerdict
      The fleet's WordPress blogsPHP, MySQL, plugins, editors who log in daily.A generator rebuild (see WordPress to static) plus a new editorial workflow.stay until the site is rebuilt static
      WordPress adminAuthenticated, stateful, writes to the database.Nothing short of leaving WordPress.stay
      MediaWiki wikisPHP + database; edit history is the product.A static export freezes the wiki; live editing is lost.stay, or freeze to static if edits stopped
      Discourse forumsRuby app, Postgres, Redis, background jobs, email.Not a Workers workload. The fleet runs them on a separate home server.stay
      HelpdeskTicket database, inbound email, agent logins.A SaaS helpdesk, not Workers.stay
      A dashboard that reads server logsIts input is the origin's own log files.Rewrite on Analytics GraphQL and D1 events.stay for now
      Webhook receivers with local stateGit checkouts, queues, files on disk.Durable Objects or Queues plus a rewrite; the fleet chose not to.stay; point their publish step at the Worker deploy
      Static snapshot of a JS appNothing: it is files.A crawl to dist/ plus generated rewrites (whopaysforai.org).move

      08 / Operating the fleet

      How do you monitor and publish many Workers static sites?

      fleet_check.py

      Per site from one sites.json: routes point at the expected Worker; host and aliases proxied; every _redirects line answers its exact status and target, and the target resolves; 200-rewrites answer 200; aliases 301/308 with path kept; listed paths 200 and gone paths 404; a random path 404s with an HTML body; the security trio on /; form health returns {"ok": true}. --crawl checks every same-origin href/src/srcset, plus declared hosts such as the R2 media domain.

      Scheduled CI publish

      A nightly GitHub Action refreshes CheatSheets' popularity data and publishes it. It reads the live version's --tag (the commit), requires it to be an ancestor of HEAD, and publishes only if git diff since then touches data files or inert paths (docs, *.md, .github/). Anything else is reported as DRIFT, a warning, exit 0, nothing published. After publishing it compares the SHA-256 of the committed JSON with the live file.

      Token scoping

      Separate tokens per job: a deploy token with Workers Scripts Edit on one account; a read-only analytics token; D1 access only where a script needs it. The CI job gets two tokens, never the local all-purpose one. Tokens live in env files outside the repo or in CI secrets.

      Analytics after nginx logs

      Page popularity comes from the Cloudflare Analytics GraphQL API; form submissions and tool beacons are D1 rows. Lost: per-request referrer parsing from access logs. CheatSheets removed its referrer report rather than fake it.

      R2 for bulk media

      Every file in dist/ counts toward the per-version cap. freecapitalists.org moved ~7,600 of ~27,900 build files (images) plus its library to R2 behind a custom domain. R2 custom domains must be a zone in the same account; r2.dev is rate-limited and meant for development. Keep the old path layout and nothing needs a redirect.

      Cache and purge

      With default headers (max-age=0, must-revalidate + ETag), a promoted version is served on the next request; Cloudflare's docs describe no purge step. Where _headers sets a longer max-age on unfingerprinted URLs (CheatSheets: /*.html 1800 s), browsers may keep the old copy that long, and no purge reaches browsers. The fleet purges the host after each promote as cheap insurance for Cloudflare-held copies; a failed purge only warns. A zone Browser Cache TTL overrides lower values you set.

      Routes, not custom domains

      All 12 hosts use a route over the existing proxied record. Custom domains would require deleting the DNS record first, which removes the instant rollback. Alias hosts get one Single Redirect rule each, appended idempotently by script, never a Worker.

      Tool versions

      npm ci installs each repo's pinned wrangler; latest is 4.143.0 (2026-09-28). wrangler triggers deploy is still marked experimental. The versions commands need wrangler ≥ 3.73 without flags; versions upload cannot create a Worker, so the first deploy is always wrangler deploy.

      09 / Incident log

      What broke during a 12-site Workers migration?

      Every row is from the migration's git history. The general rule is the part to keep.

      DateSymptomRoot causeFixGeneral rule
      2026-09-27vellum.capital/whitepaper redirect gone after cutoverA Forwarding URL Page Rule; Page Rules are ignored for requests a Worker route servesRecreated as a zone Single RedirectInventory Page Rules per host before adding a route; convert every forwarding rule first.
      2026-09-27Parity failed on identical pagesCloudflare-injected markup: beacon script and its newline, Cloudflare Fonts, Google Fonts links, HTTPS rewrites of http:host, email obfuscationNormalizers applied to HTML before hashingDiff the bytes readers receive only after removing what the edge adds to both sides.
      2026-09-27/28Four sites shipped different bytes from Windows buildsCRLF checkouts.gitattributes: * text=auto eol=lf in the scaffoldA build must be byte-identical on every OS that can deploy it.
      2026-09-27/28Eleventy builds differed by machineDates rendered in the builder's local time zone (Denver)TZ=UTC in the build commandPin time zone and locale in any build that prints dates.
      2026-09-27Parity flagged Strict-Transport-Security: max-age=0 on one zoneZone HSTS setting enabled with max_age 0: no policyAllow-listed with its reason; parity made it visibleVerify security settings by the header value, not the dashboard toggle.
      2026-09-28Six sites missing X-Frame-Options, nosniff, Referrer-Policynginx added them server-wide; parity compared headers only after those sites movedRestored in _headers and script code; fleet_check now requires them and an HTML 404Port the server config, not just the vhost; gate on it.
      2026-09-28Fleet check reported 175 bogus failing paths<image:loc> R2 URLs in the sitemap were read as apex page pathsSitemap parsing restricted to page URLsParse sitemaps by element, not by URL-shaped strings.
      2026-09-28Live verification failed on large pages; read as 6 to 15 min "edge lag"echo "$BODY" | grep -q under pipefail: grep exits early, echo dies of SIGPIPE (141) on any page over the 64 KB pipe bufferHere-string: grep -qF -- "$S" <<<"$BODY". The longer timeouts and purge step added during the misdiagnosis landed 17 min before the real fixProve the verifier on a known-good page before blaming the platform.
      2026-09-28Scheduled publish exited 22 after publishing correctlycurl -f inside $(…) under set -e, plus Bot Fight Mode challenging the CI runner (403)Tolerant fetch in the verify loop; fall back to wrangler deployments statusVerification code needs the same error handling as deploy code, and CI egress IPs get challenged.
      2026-09-28Forms deploy printed "preview only" after going liveThe JSONC "is it routed?" regex had single backslashes in a raw string; Python raised; the fallback branch assumed unroutedRegex fixedA detection failure must stop the script, not pick a default branch.
      2026-09wrangler crashed with EISDIRA directory named _redirects/ in the assets rootStaging writes a file, never a directory, at that name_redirects and _headers are reserved file names in assets.directory.

      10 / Common mistakes

      Common mistakes moving static sites to Workers

      • Leaving forwarding Page Rules on a routed hostThey stop firing the moment the route exists, and parity cannot see it. Fix: convert to Single Redirects before cutover.
      • Trusting html_handling for legacy URLsIt answers 307, and only for forms of files that exist. Slashless legacy URLs 404. Fix: explicit 301 lines for every variant with backlinks.
      • Assuming _headers covers script responsesIt applies to assets only. A run_worker_first page ships without security headers. Fix: set them in code too.
      • Leaving not_found_handling at its defaultnone returns a bare 404 with no page on an assets-only site. Fix: 404-page + 404.html.
      • Deploying HTML before its form WorkerSubmissions 404 until the forms route exists. Fix: forms Worker first, or a service binding.
      • Setting secrets on an upload-only Workerwrangler secret put refuses when the latest version is not deployed. Fix: wrangler versions secret put.
      • Connecting Git buildsEvery push goes live and the human approval gate disappears. Fix: local guarded deploys; CI only for guarded data-only publishes.
      • Publishing origin detailsOrigin IPs, SSH hosts, server paths, and account/zone/database ids in public docs let attackers bypass Cloudflare. Fix: placeholders; describe the origin generically.
      • Believing a challenged parity runWhen Bot Fight Mode challenges most probes, "no differences" means nothing. Fix: treat more than max(5, 10%) challenged as exit 2.
      • Blaming propagation firstVersion promotion is fast; a verifier that fails intermittently on big pages is more likely broken. Fix: test the checker against a known-good page.

      11 / Sources

      Sources

      Cloudflare docs read 2026-09-28. Fleet repositories other than CheatSheets are private and cited as "fleet repository".

      David Veksler is a Principal AI Engineer in Denver. He leads agentic AI engineering at Antech, a Mars company, and builds AI platforms for regulated financial firms. This page was produced by a governed, multi-agent Claude Code pipeline with a git audit trail. How it's built The regulated-lender case study