A Static Site on Cloudflare Pages: The Migration That Shut Down Containers
Hosting a static site on Cloudflare Pages comes down to this: your framework’s build produces a folder of HTML/CSS/JS. One npx wrangler pages deploy pushes the folder to the CDN, the network that delivers the files. Your own domain comes in via CNAME, a DNS record. And serving the files costs nothing: on both the free and paid plans, requests to static assets are free and unlimited (pricing documentation, checked 2026-08-25). No server, no container, no open port, no Docker image to patch on a Saturday. The hard part is not the deploy. It is admitting that your “dynamic” app may never have needed to be dynamic.
This post documents a real migration from the house: Tradutor Regional (tradutor.haruo.dev), a translator of Brazilian regional expressions, with 696 active expressions across 6 dialects. It ran as two containers, isolated processes: one with the Next.js app, one with Postgres. Both sat on the same VPS that serves everything through Cloudflare Tunnel with zero open ports.
In August 2026 it became a static export, a folder of ready-made files served by Cloudflare Pages. The containers were shut down, and the site is still up: same look, same translation, now running 100% in the browser. What follows is the decision criterion, the migration path, the shutdown checklist, and the honest limits.
When is a “dynamic” app actually static?
The app wore the full dynamic-app costume: Postgres with Prisma, API routes, an admin panel with login (next-auth), rate-limit middleware (which blocks excess requests). All of it existed. And all of it was infrastructure for answering a question nobody asked: what if the data changes right now?
The criterion that decided the migration fits in one question: does anyone besides the owner write?
- The only writer was the owner. The expression dictionary changed when the owner edited it, which was rare. Visitors only read. One human writes now and then; the entire rest of the world only reads. That is not a database: it is a content file wearing a database costume, charging container rent to keep the costume on.
- The only visitor write was, in practice, a message. The suggestion form POSTed to an API route. But every suggestion went through the owner before entering the dictionary. A write moderated by one person is an inbox, not a transaction. It became a
mailto:to the owner, and nothing of value was lost. - The data fits in the browser. The 696 expressions serialize to a ~270 KB JSON, smaller than many a hero-section photo. If your entire dataset fits in a few hundred KB, the browser handles it effortlessly. If it does not, you can still split it per route at build time. The real limit shows up much later than intuition suggests.
The second question, a corollary of the first: what is left of the server when nobody writes? In this case, an admin with nothing to administer. A rate limit protecting an API that could stop existing. And a healthcheck, the automated health probe, watching a process that only repeated the contents of a file. The honest answer was “nothing”.
What did the migration from Next.js with a database to a static export look like?
Next.js has a mode for exactly this. output: 'export' in next.config makes next build produce a folder of pure HTML/CSS/JS, hostable on any static file server (official static exports guide). The mode is also a contract about what you lose. These stop existing: API routes that depend on the request, headers()/redirects()/rewrites() in the config and middleware. So do ISR (incremental static regeneration), cookies, Server Actions and the default image optimization. The whole migration is deciding, piece by piece, what each server feature becomes:
| Lived on the server | Became |
|---|---|
| API routes querying Prisma | A client-side module reproduces the same responses over /data/expressoes.json (~270 KB), generated at build time from the database’s final dump |
| The translation function running on the server | The same function, unchanged, running in the browser. Parity verified sentence by sentence (12 of 12) against the still-live API, before shutting anything down |
| Admin, login, next-auth, rate-limit middleware | Deleted: there is nothing left to administer and no API left to limit |
| Suggestion form (POST to the API) | A mailto: to the owner. The moderated write already was a message |
headers() in next.config | public/_headers, the Pages convention for security headers |
| Dynamic OG image on the share page | Lost. The link preview on WhatsApp has no image now, only a title. A cost accepted, and said out loud |
| Dashboard showing process uptime | Shows the date of the dictionary’s last update. A static site should not pretend to be alive |
Two details that spared pain:
trailingSlash: trueturns/routeinto/route/index.html, the shape any static host serves without a special rewrite rule.- The final artifact is versioned. The exported folder (~2 MB, 79 files) went into git: it is the entire site, republishable from any machine, no Node installed. A versioned static site is its own backup. That connects to the house’s 3-2-1 backup design: one less database in the nightly dump rotation.
And if your case does need to keep an admin? The house solves that without writing a login page, with identity at the edge. The design is in the post on Cloudflare Access. But note the order of the questions: first “does this admin need to exist?”, only then “how do I protect it”.
How do you publish on Cloudflare Pages?
The manual path, from zero to live:
npx wrangler login
npx wrangler pages project create my-site --production-branch main
npx wrangler pages deploy # reads pages_build_output_dir from wrangler.toml
The deploy returns a preview URL (https://<hash>.<project>.pages.dev). Test there before touching DNS. Your own domain goes in under the project’s Custom domains: Cloudflare creates the CNAME and the certificate on its own, active in minutes.
The house’s case had one extra step, because the hostname belonged to a Cloudflare Tunnel. The swap was to delete the tunnel’s public hostname and add the custom domain on Pages. The unavailability window lasted seconds, and the site left the VPS without touching any other record.
For those who prefer not to deploy by hand: Pages also builds from the repository (git integration: a push to the production branch publishes). That is how haruo.dev itself, where this post lives, is published.
How do you shut the containers down without breaking anything?
The order matters more than the commands. The principle that organizes the checklist is this: the hostname changes owners before anything dies. An orphan subdomain serving 5xx, the server errors, contaminates the index report of your entire domain in Search Console. The house learned that the expensive way.
- A final database dump, with its hash recorded. A dated
pg_dump, its sha256 written down in the sunset documentation, stored off the machine that is about to die. It is what lets you regenerate the static JSON two years from now. The data outlives the database. - Publish the static site and test it on the preview URL. Every route, the JSON, the 404. All before any DNS change.
- Swap the hostname (tunnel → Pages custom domain) and verify with
curl: status 200, security headers present, right content. - Only then take the containers down:
docker compose downwithout-v. The database volume stays intact through a grace period (7 days, in this case). A rollback during that window costs anup -dand recreating the route. That is minutes, not a restore. - Clean up the surroundings: the database backup cron dies with the database. The hostname’s uptime monitor stays: the address still answers 200 through Pages. Monitors are for addresses, not containers.
- After the grace period: delete the volume, the image and the directory. Only once the static site has proven it stands on its own.
Notice what the checklist does not contain: any moment at which tradutor.haruo.dev answers with an error. The old site served until the minute of the swap; the new one served from that minute on. Googlebot never saw a 502. That is exactly what the ordering buys.
What does hosting a static site on Cloudflare Pages cost?
Public numbers, checked on 2026-08-25 in the limits documentation and the pricing documentation:
- Serving static assets: $0. “Requests to static assets are free and unlimited”, on both free and paid plans. No bandwidth meter, no traffic-bill surprise.
- Free plan: 500 builds/month, up to 20,000 files per site (100,000 on paid plans), 25 MiB per file, 100 projects per account, 100 custom domains per project.
- This post’s case: 79 files, largest file ~270 KB, an occasional deploy. Orders of magnitude of headroom inside the free plan.
And one piece of honesty about what “zero cost” means here: the house’s bill did not change. The VPS still exists for the other services.
What went to zero was this site’s footprint. Gone are two containers to update, a database to dump every night, a volume to watch and one more healthcheck on the list. Every container is a recurring maintenance tax, and the migration cancelled the subscription. If your site is the only tenant of a server, then yes, the money bill zeroes out too. But that is the exception, not the promise.
When is Cloudflare Pages NOT enough?
The first section’s criterion cuts both ways. Pages (like any static host) does not solve your problem when:
- Someone besides the owner genuinely writes. User accounts, comments, orders, any transaction: that is a backend. Pages Functions and Workers exist to add server code, but at that point you have left “static for free”: requests to Functions count against the Workers quota.
- The data changes per request. Real-time prices, stock, personalization, content behind a login. None of that survives a build that runs once in a while.
- The dataset neither fits in the browser nor splits at build time. The ~270 KB JSON works; a searchable catalog of hundreds of MB needs an API in front of it.
- You depend on what the export discards. Per-page OG images, framework redirects/headers (Pages’
_headers/_redirectscover the simple cases, but they are a different tool), middleware, ISR. The migration’s table of losses above is the list price. Pay it with open eyes.
One footnote comes from the product itself, checked on 2026-08-25. The Pages documentation now recommends Workers for new projects: “Workers supports most Pages use cases… Start new projects with Workers”. Pages keeps serving the sites already on it (this blog included), and static assets on Workers is the same design on a different chassis. For a new site, it is worth considering starting there. For a migration like this post’s, Pages remains the shortest path between “I have a folder of HTML” and “it is live, for free”.
The summary you take home
- The criterion: does anyone besides the owner write? No → the database is a content file wearing a database costume, and the site is static. A write moderated by one person is an inbox (
mailto:will do), not a transaction. - The path:
output: 'export'in Next.js, data in a public JSON generated from the final dump, the same server function running in the browser. Parity was tested against the live API before shutting anything down. - The losses have names: dynamic OG images, framework
headers()/redirects(), middleware, anything per-request. List them first, accept them in writing. - Publishing:
wrangler pages deploy, test on the preview URL, custom domain via CNAME, security headers in_headers. - Shut down in order: dump with hash → publish → swap hostname →
downwithout-v→ grace period → cleanup. The hostname is never orphaned: a dead subdomain’s 5xx poisons Search Console for the whole domain. - Cost (2026-08-25): static assets free and unlimited; 500 builds/month and 20,000 files per site on the free plan. What zeroes out for certain is the maintenance tax; the bill, only if the site was the server’s sole tenant.
- Pages is not enough when there is real user writing, per-request data or a large dataset. And, for a new project, Cloudflare itself now points to Workers with static assets.
Infrastructure that scales without breaking the bank
Cloud bill out of control? I run my own on a single VPS with no open ports, automatic deploys and healthcheck-gated rollback. The whole design is published here.
Read the infrastructure posts →