Static was the easy half.
Putting a folder of HTML on Cloudflare Pages takes five minutes. Then someone asks for a contact form, and suddenly you need code that runs on a server, somewhere to keep what people send, a secret that must never reach the browser, and a domain that is yours rather than a subdomain of someone else's.
The surprise is that none of that leaves the free tier. The other surprise is that the hard parts are not the code — they are four traps that cost me an evening each.
This one is for developers. It assumes you have already put a static site on Cloudflare Pages and now need it to do something — take a form submission, remember it, and live on your own domain. Every number here comes from Cloudflare's own documentation, checked in September 2026.
The wrong turn I took first
I want to start with the mistake, because it is the one thing a tutorial will not tell you and it wasted the most time.
Cloudflare’s dashboard has a big blue Create application button. In 2026 that button walks you into Workers Builds — connect your GitHub repo, set a build command, done. It looks like the obvious front door. I clicked it, connected my repository, and got this:
Three things are wrong in that little box, and they are worth naming one by one.
wrangler deploy is the Workers command, not the Pages command. They are different products with different deploy verbs. A Pages project is deployed with wrangler pages deploy <directory>. Running the Workers verb against a Pages configuration gets you an error that reads like a bug in your code.
The build command was empty. The log went straight from npm clean-install to the deploy step, so there was no built output to deploy at all. It was shipping nothing, loudly.
It was not even the right project. Cloudflare created a new application from my repository name. My real site lived in a Pages project called something else entirely, with the database and the secrets attached to it. Even a green build would have deployed a stranger.
A successful deploy to the wrong project is worse than a failed one.
I deleted the whole thing and went back to deploying from my laptop. Which, it turns out, is the better answer anyway — for a reason that has nothing to do with Workers versus Pages.
My website lives inside a larger private repository that also holds job applications and cover letters. Connecting a Git repository hands the whole repository to the build system. Deploying by direct upload sends only the built output folder — so the rest of the repo physically cannot travel. If your repo is a clean single-purpose one, Git connection is genuinely convenient. Mine is not, and "the private files cannot leak because they are never sent" beats any amount of careful configuration.
Here is what a correctly configured project looks like afterwards. Note the line under the name:
“No Git connection” is not a warning. On a direct-upload project it is the design.
The shape of a dynamic Pages site
Before the how, the what. A Pages site with a backend has exactly two kinds of request, and Cloudflare decides between them by looking at the path.
The mental shift that took me longest: the function does not stay running between requests. There is no process holding a variable. I originally kept a rate-limit counter in a JavaScript Map, which worked perfectly on my laptop and did nothing at all in production, because every request got a fresh, empty Map. Anything that must be remembered between two requests has to go into the database. That is not a limitation to work around; it is the entire model.
Adding a database with D1
D1 is Cloudflare’s SQLite database. It is real SQL — tables, indexes, JOIN, transactions — and on the free plan it costs nothing.
Create it
One command. It prints a UUID, which is the database's id.
npx wrangler d1 create my-leads
Bind it to the project
A binding is how your code gets a handle on the database. It goes in wrangler.toml next to your Pages configuration:
name = "my-site"
pages_build_output_dir = "dist"
[[d1_databases]]
binding = "DB"
database_name = "my-leads"
database_id = "the-uuid-from-step-1"
That id is a name, not a password — it is safe to commit. The credentials that are secret come later.
Create the tables
Write the schema as an ordinary .sql file and apply it. --local hits the copy on your machine, --remote hits the real one.
npx wrangler d1 execute my-leads --local --file=./schema.sql
npx wrangler d1 execute my-leads --remote --file=./schema.sql
You will forget --remote at least once and wonder why production has no tables. Everyone does.
Use it
The binding arrives as env.DB. Always use bound parameters — never build SQL by joining strings.
const result = await env.DB
.prepare('INSERT INTO leads (name, email, message) VALUES (?, ?, ?)')
.bind(lead.name, lead.email, lead.message)
.run();
const id = result.meta.last_row_id;
Read it back
You do not need a GUI. The same command runs any query against the live database:
npx wrangler d1 execute my-leads --remote \
--command "SELECT * FROM leads ORDER BY id DESC LIMIT 20"
I wrapped that in a one-word script so checking enquiries is ./run leads --remote. A five-line wrapper you will use weekly is worth more than an afternoon of dashboard-clicking.
What the dashboard tells you
The D1 overview page is one of the more useful screens Cloudflare has, because it answers “am I anywhere near the limits” at a glance. Mine, after a day of real traffic:
Now hold those against the free plan, which as of September 2026 allows 5 million rows read per day and 100,000 rows written per day.
The point of showing my own unimpressive numbers is calibration. A portfolio contact form, a small dashboard, a side project with a few hundred daily visitors — these do not approach a free D1 allowance. The free tier is not a teaser that runs out the moment anyone visits. What does burn rows is a query pattern that reads a whole table on every page load; the fix there is an index, not a bigger plan.
D1 keeps a point-in-time history of your database: 7 days on the free plan, 30 on paid. If you run the wrong DELETE against production — and one day you will — you can restore to a timestamp before it happened. It is on by default and costs nothing extra. Knowing it exists before you need it is the whole trick.
Variables and secrets
This is where I nearly shipped something embarrassing, so it gets a section of its own.
There are two kinds of configuration on Pages and they behave nothing alike. Getting them the wrong way round is either a broken site or a leaked credential.
Set a secret once, from the terminal:
npx wrangler pages secret put TELEGRAM_BOT_TOKEN --project-name my-site
npx wrangler pages secret put TURNSTILE_SECRET_KEY --project-name my-site
It prompts, you paste, it is encrypted and stored. You cannot read it back afterwards — not through the CLI, not in the dashboard. That is the feature. Lose it and you rotate it; there is no recovery, and there does not need to be.
Public build-time values go in a .env file instead, and by convention are prefixed so nobody is confused about what they are:
PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAAA...
Test keys pass every check, including a bot's
Turnstile ships an always-passes test key so a fresh checkout runs without credentials. It is a genuinely good idea, and it is a landmine: a form protected by the test key looks completely normal, submits successfully, shows a tick — and stops precisely nobody.
A broken bot filter announces itself. A test key does not. You find out weeks later, from the spam.
The fix: make deployment refuse to run. My deploy script greps .env for the known test key and exits before uploading if it finds it. Ten lines. It has already stopped me once, on a tired evening when I would certainly have shipped it.
Updating the site
Once the wiring is done, this is the whole workflow — and it is worth designing it to be one command, because a deploy you have to think about is a deploy you will do badly at 1am.
./run deploy
Behind that, in order:
Updating the database is a separate motion, and deliberately so. Schema changes ship as SQL files applied with wrangler d1 execute --remote. Keeping them out of the deploy path is a feature: you almost never want “push the site” and “alter a live table” to be the same button.
Pointing your own domain at it
Every project gets a free <name>.pages.dev address and a working certificate immediately. Attaching your own domain takes about a minute.
In the dashboard: Workers & Pages → your project → Custom domains → Set up a domain.
What happens next depends on where your DNS lives.
- Confirm the record and Cloudflare adds the CNAME itself.
- Apex domains work — Cloudflare flattens the CNAME for you, which ordinary DNS cannot do.
- The certificate is issued automatically.
- A subdomain like
wwwjust needs a CNAME to<project>.pages.dev. - For the bare apex you must point your nameservers at Cloudflare.
- Check for a CAA record that blocks Cloudflare from issuing certificates — a rare problem with a confusing error.
Two things worth knowing afterwards. The pages.dev address keeps working — it is not replaced, and it stays useful as a way to check a deployment without touching the real domain. And the certificate covers your domain automatically, with renewal handled for you; there is no expiry date to diary.
Four traps that cost me time
Deploying to a project that is not yours
The Create-application wizard names the new project after your repository. If your real project is called something else, you now have two: a working one nobody is deploying to, and a broken one collecting failed builds.
The fix: always pass --project-name explicitly, and check the list in the dashboard before assuming a green deploy went where you think it did.
Your deploy lands on a preview URL and production never changes
Pages compares the branch you deployed from against the project's production branch. If they differ, you get a preview deployment — a real, working, correctly built site on a URL nobody visits. The deploy succeeds. The site does not change. There is no warning anywhere.
The fix: set the production branch to the branch you actually deploy from when you create the project, and read the URL in the deploy output rather than assuming.
Generated files are in .gitignore, and a Git build cannot generate them
This is the one that decides the whole architecture. My build needs files that are produced by a script — generated JSON, images copied from elsewhere in the repo — and those are gitignored, because committing generated output is how two copies drift apart. Building on my laptop, the script runs first and everything is there. Building from a Git clone, the files simply do not exist and the build fails on a missing import.
The fix: either commit the generated output, or install the generator in the build image, or build locally and upload the result. I chose the third because it also keeps a private repository private.
The silent notification failure
My function saves the enquiry first and sends the notification second, on purpose — a stored message beats a delivered one. The consequence is that if notifications break, the form still says "sent", because it was: the row is in the database. Nothing is lost, but nothing tells you either. I found out because a delivery flag in the table was 0.
The fix: a command that sends one real test notification end to end, run before you trust it and after any change. Mine also verifies the escaping by sending a message full of reserved characters — a failure mode I would never have caught by hand.
What it actually costs
Every figure below is from Cloudflare’s own documentation, as of September 2026. The terms do move, so check them rather than trusting a blog post — including this one.
| What | Free plan | What that means in practice |
|---|---|---|
| Static requests | Unlimited | Pages, images, stylesheets. Traffic on the static side is genuinely uncapped. |
| Function requests | 100,000 / day | Only /api/* style calls count. Form submissions, not page views. |
| CPU per invocation | 10 ms | Ample for a database write and an HTTP call. Not for image processing. |
| D1 rows read | 5,000,000 / day | Effectively unreachable for a small site with sane queries. |
| D1 rows written | 100,000 / day | The scarcer of the two. Watch this one first. |
| D1 storage | 500 MB per database, 5 GB total | Up to 10 databases on the free plan. |
| D1 Time Travel | 7 days | Point-in-time restore after a bad query. On by default. |
| Queries per invocation | 50 | A per-request budget. Loops issuing one query each will find it. |
| Files per deployment | 20,000 | Generous unless you ship a large image gallery. |
| Max file size | 25 MiB | Video belongs somewhere else regardless. |
| Custom domains | 100 per project | Certificates included and renewed for you. |
One line worth adding, because it surprised me: Cloudflare’s free plan includes 500 builds per month — but that quota is for builds Cloudflare runs. Build on your own machine and upload the result and you never touch it. Direct upload is not only the safer path for a private repository; it is the one with no build budget at all.
Where to read more
pages deploy, d1 execute and pages secret. The page to keep open while you set this up.
THIS SITE
Host a website free on Cloudflare Pages
The part before this one: what Pages is, why it is free, and how the CDN works — written for non-developers.
One last thing
The thing I did not expect was how much of this was deciding, not building. The code for a contact form is an afternoon. Choosing direct upload over a Git connection, deciding that the database write comes before the notification, making the deploy script refuse to ship a test key — those took longer and mattered more.
Every one of them came from the same question: when this breaks, how will I find out? A Git build that deploys the wrong project, a preview URL that looks like production, a bot filter that passes everything, a notification that silently stops — all four failed quietly. None of them threw an error. That is what made them expensive.
So the practical advice, more than any command in this post: after you wire something up, break it on purpose and check that you notice. If nothing tells you, add the thing that will.
