The Cloudflare products I actually use

By

An honest report on the Cloudflare products I use in real projects, what each one solves, the gotchas I found, and what I do not use yet.

~~~

Cloudflare has a lot of products.

Open the dashboard and the sidebar goes on for a while. Workers, Pages, KV, D1, R2, Queues, Durable Objects, Workflows, Vectorize, AI Gateway. Everything looks useful and everything seems to plug into something else. It’s easy to pick a product first and then go looking for a problem to give it.

I try to do the opposite. I start from a problem I have, and I add the smallest thing that makes it go away.

I already have a Cloudflare guide that links to the individual tutorials. This post is about what I run in production, on this site and on one other app, why it’s there, and where it bit me. The full site picture, with Astro, Pages, Worker rebuilds, Pagefind, Plausible and Sendy, is in the stack I use to run this blog, 2026 edition.

I’ll also tell you about the products I studied and wrote about but don’t use. On a platform this big, knowing what not to add is a good part of the job. I did the same kind of inventory for the MCP servers connected to my editor.

DNS

The least exciting product on the list is the one I’ve used the longest.

DNS maps a domain name to whatever answers requests for it. If you own yourapp.com, DNS is what tells browsers where yourapp.com lives.

Cloudflare was already managing the DNS for this site before I moved the hosting to Cloudflare Pages. When I did move, the cutover was mostly a matter of pointing the existing domain at the Pages project. Nothing else about the site’s identity changed.

I like keeping the two things separate in my head. The domain is the stable part. Hosting is the part that can change. When you switch platforms you update a record, and that’s it.

Then there’s the orange proxy switch. When you turn it on, requests pass through Cloudflare before reaching your origin, and you get HTTPS, caching, and traffic controls at the edge.

It also changes how traffic flows, and that caught me once. If the origin redirects HTTP to HTTPS on its own, or expects a different TLS mode than the one Cloudflare is using, you end up in a redirect loop. I wrote about fixing too many redirects after enabling the Cloudflare proxy because I hit exactly that.

Since then I change one layer at a time. First get DNS resolving. Then turn on the proxy. Then check HTTPS and redirects with curl:

curl -I https://flaviocopes.com

The response headers tell you far more than a browser error page does.

Pages

This site is built with Astro. Almost every page is static. Astro generates the HTML, the images, the RSS feed, the sitemap, the tag pages, and every blog post at build time.

Cloudflare Pages is a good fit for a site like that.

The build command is the usual one:

npm run build

The output goes into dist, and the relevant part of wrangler.jsonc is tiny:

{
  "name": "flaviocopes",
  "pages_build_output_dir": "./dist"
}

I push to Git, Cloudflare runs the build, and the generated files go live.

The big win is that there’s no web server anymore. No Nginx config, no VPS to copy files to, no certificates to renew, no process to keep alive. I hand over a folder of static files and Cloudflare serves them. Most of the work happens during the build, so there’s very little left to break when a request comes in.

The one thing that tripped me up was configuration ownership.

Once pages_build_output_dir is in wrangler.jsonc, that file becomes the source of truth for the Pages project settings. I had picked a newer Node version in the dashboard at some point. After adding the config file, that dashboard value no longer did anything.

Cloudflare’s build image defaulted to Node 18.17.1, and the Astro version I was on needed something newer. The build broke.

The fix was a .node-version file in the repository:

24.15.0

Now the runtime the build needs is written down next to the code, and I don’t depend on a dashboard setting I might forget exists.

Pages Functions

A static site usually has a couple of things that can’t be static.

Here it’s the course system. One route receives purchase webhooks. Another one lets past students get their course access links back.

They’re two files in the functions directory:

functions/purchase.js
functions/api/course-access/send.js

Pages turns them into routes based on the path:

/purchase
/api/course-access/send

The purchase function verifies the signed webhook, records the access, subscribes the buyer to the right list, and sends the email. The retrieval function checks a form submission, looks up the course data, and emails the result. There are a few more small routes for the interactive tools on the site, built the same way.

This is what I like most about Pages. The site stays static, I add a handful of explicit server-side endpoints, and I never have to turn the whole project into a server-rendered app to get them.

I did get the boundary wrong once, though.

For a while the project had a root Pages middleware doing content negotiation. What I hadn’t realized is that a root middleware puts every request through a Worker, including every image, CSS file and asset, not only the pages. A small feature turned every single page view into a metered function invocation.

I removed it. Getting rid of the requests was a better fix than making them cheaper.

If I ever add root middleware again it will come with a _routes.json file that limits which paths reach the function. More generally, on Cloudflare I now always check which requests run code.

Workers

Pages Functions run on the same model as Cloudflare Workers, so let’s look at that model on its own.

A Worker receives standard web objects: Request, Response, Headers, URL. The smallest one looks like this:

export default {
  async fetch(request, env) {
    return new Response('Hello')
  },
}

If you know the Fetch API from the browser, most of this is already familiar. There’s not much framework to learn.

Bindings come in through env. A KV namespace, a database, secrets, they all show up there. Reading from KV is one line:

const value = await env.COURSE_ACCESS.get(email)

The function stays small, because the platform services it needs are attached in configuration.

The mistake to avoid is treating the runtime as Node.js. Workers support a lot of Node APIs, especially with the nodejs_compat flag, but this is not a long-running Node process.

The purchase webhook on this site verifies signatures with node:crypto, so the project needs this:

{
  "compatibility_date": "2024-11-01",
  "compatibility_flags": ["nodejs_compat"]
}

The compatibility date pins runtime behavior and decides which features you get. If you copy a wrangler.jsonc from an old project, check the date and the flags before trusting it.

I cover the basics in Cloudflare Workers: your first serverless function.

Wrangler

The dashboard is fine for looking at things. For changing things I want something I can repeat, and that’s Wrangler, Cloudflare’s CLI.

I use it to run code locally, create resources, set secrets, inspect deployments, and wire up bindings. The commands all follow the same pattern.

Run a Worker locally:

npx wrangler dev

Deploy it:

npx wrangler deploy

Add a secret to a Pages project:

npx wrangler pages secret put RESEND_API_KEY --project-name flaviocopes

What I like most is that wrangler.jsonc sits in the repository next to the code. I open one file and I see the output directory, the compatibility settings, the public variables, and the KV bindings, so the infrastructure is written down in the repo.

Not everything goes in there, though.

The Turnstile site key lives in wrangler.jsonc because it ends up in the page HTML anyway. Anyone can read it. The Turnstile secret key, the Resend key, the Paddle key, those go into Cloudflare’s secret store with wrangler pages secret put and never touch the file.

I mention it because the word “key” doesn’t tell you which kind you’re holding. The site key is public and the secret key is not, so check what a value does before deciding where it lives.

I wrote a separate Wrangler guide with the commands I use most.

KV

KV was the first storage product I used here.

It stores a value under a key. That’s the whole API, more or less: get, put, delete, list.

The course retrieval flow uses an email address as the key. The value holds the course access entries tied to that address. The function reads it as JSON:

const data = await env.COURSE_ACCESS.get(email, {
  type: 'json',
})

A second namespace holds usage counters for the AI-assisted tools. Each counter has a known key and expires after two days. There are no relations, no reports and no sorting of thousands of rows, only “what belongs to this key”.

The binding looks like this:

{
  "kv_namespaces": [
    {
      "binding": "COURSE_ACCESS",
      "id": "the-production-namespace-id",
      "preview_id": "the-preview-namespace-id"
    }
  ]
}

For lookups like these I get a fast read from anywhere in the world without standing up a database.

Keep in mind that KV is eventually consistent. It’s built for reads that happen everywhere, so a write may take a moment to show up in every location. That’s fine for access lists, caches, preferences, and configuration, and it’s not fine for a bank balance or an exact global counter.

Something else I learned while building the purchase flow is a Workers thing rather than a KV thing. Await the network calls that matter before you return the response. On a traditional server the process keeps running after the handler returns. A Worker isolate can be stopped. I once had a subscribe call get cancelled because I didn’t await it. If the work matters, await it, or attach it to the request lifecycle explicitly.

The basics are in Cloudflare KV: a key-value store for your Workers.

D1

This site does not use D1. The blog posts are files and I want them to stay files.

I do use D1 in another production app, where the data is relational. Users, saved reports, provider information, products and settings are table-shaped data, and they need indexes, constraints, filters, and migrations.

One concrete Workers + D1 example is native email/password authentication, where user accounts and sessions live in D1.

D1 is Cloudflare’s SQLite database. The code gets a binding like env.DB, and Wrangler handles the database and the migrations.

A query can be very short:

const user = await env.DB.prepare(
  'select * from users where email = ?'
).bind(email).first()

I get SQL without running a database server. No machine to provision, no port to open, no connection pool, no OS upgrades.

Be careful with the word “SQLite”. It’s easy to assume D1 behaves like a local .db file, but it’s a managed distributed service with its own limits, its own transaction behavior, and its own migration workflow, and you need to know those before you lean on it.

I use migrations from day one. Even a tiny app grows, and editing production tables by hand does not scale past the second change.

The workflow is in Cloudflare D1: a SQL database for your Workers.

Choosing between KV and D1 has never been hard for me. A key and a value, KV. Entities with relationships, D1.

Turnstile

Public forms attract bots. The course access form on this site runs through Cloudflare Turnstile before it sends anything.

There are two halves. The browser renders a widget using the public site key:

<div
  class="cf-turnstile"
  data-sitekey="your-site-key">
</div>

The widget produces a token. The Function then sends that token to Cloudflare’s siteverify endpoint with the secret key, and only does the real work if the check passes.

For real people it’s nearly invisible. Most of the time there’s no puzzle at all, so nobody has to click on traffic lights, buses or bridges.

The mistake I see people make is stopping at the widget. The widget alone protects nothing, because a bot can skip your page and POST straight to the endpoint. If the server doesn’t verify the token, the form isn’t protected.

The browser widget only collects a token. The server has to verify it.

The complete flow is in Cloudflare Turnstile: stop bots without annoying CAPTCHAs.

The Pages build cache

Build caching sounds like a detail, but on a site with around 1,800 posts it turned out to matter a lot.

The build generates Open Graph cards for courses, tools, topic pages, and other key pages. Regenerating the same cards on every deploy is wasted time.

Pages can keep some cache directories between builds. For Astro it preserves node_modules/.astro when the build cache is enabled.

The OG image generator was writing its cache somewhere else. Cloudflare threw that away after every build, so every card got rebuilt every time. I moved the cache under the directory Astro already keeps:

cacheDir: './node_modules/.astro/astro-og-canvas'

A cold build took about 45 seconds. A warm one now takes about 12.

The catch is that Pages does not preserve arbitrary directories. It has an allow-list of framework cache locations. If a tool writes elsewhere, you move its cache, not the allow-list.

I wrote up the details in How the Cloudflare Pages build cache works.

R2

The books and the generated course downloads live in R2, served from downloads.flaviocopes.com.

Those files don’t belong in the Pages build. They’re large, they have their own generation and upload steps, and I shouldn’t need to rebuild the whole site to ship a new PDF. The repository keeps a small manifest with the expected files and their metadata. The actual PDFs and EPUBs stay out of Git and out of the Astro output.

So publishing and delivery are separate. A normal build validates the manifest and writes the links. Uploading a download is its own explicit command. I can change the website without re-uploading every book, and a big PDF doesn’t make the repository heavier.

That separation is also a place where things can drift. A link can be perfectly valid in the HTML while the object is missing from the bucket. That’s why the production build checks the manifest, and why I verify the uploaded files as a separate step. The custom domain helps too, because the public URLs stay the same even if I change how the files are stored behind them.

Normal article images still live in the repository. They’re part of the pages and they benefit from the static build. R2 is only for the large downloads.

A Cron Trigger for scheduled posts

Astro leaves future-dated posts out of the build until their publication time. The problem is that a static site doesn’t rebuild itself when that time arrives.

So I run a very small scheduled Worker. It fires a few minutes after each publication slot and calls a Pages deploy hook. That’s all it does.

Daylight saving time was the annoying part. A cron expression is in UTC and Rome moves by an hour twice a year. The Worker registers both UTC offsets for each slot and then checks the Rome wall-clock time before doing anything, so I never have to edit the schedule by hand. There’s a GitHub Actions schedule as a backup.

I like that the Worker doesn’t publish anything itself. It asks Pages to run the same build a Git push would run. The rules about what’s published live in Astro, in one place.

What you do have to watch is the build queue. A deploy hook can return without starting a new useful build if one is already queued, and a build stuck in the active state blocks everything behind it. A scheduled system still needs a way for you to notice when it silently didn’t work.

Workers AI

One of the interactive tools on the site uses a Workers AI binding.

The browser never sees a model credential. The request goes to a Pages Function, which verifies Turnstile, checks hard daily counters in KV, and only then calls a small model through the binding.

This is a very narrow use of AI on purpose. The model helps with one bounded task. It doesn’t drive the site and it doesn’t get a general tool API.

The binding follows the same env pattern as KV and D1, which is nice. The part you can’t skip is cost. A cheap model is still not free when a public endpoint can be hammered. I keep both a global daily cap and a per-client daily cap, and I check them before the model call happens, not after.

Products I know but don’t reach for

I wrote tutorials about Queues and Durable Objects. Neither is in this site’s request path.

I’m saying this because when you write about a tool, readers assume you use it everywhere. I don’t.

Queues would make sense when a request needs to hand off reliable background work to another process. The purchase flow here is small enough to do its work inline. I’d add a queue if the flow grew, if retries needed stronger isolation, or if processing started taking too long.

Durable Objects are for when many requests need to coordinate through one authoritative, stateful object. A chat room, a live collaboration session, a precise rate limiter. A blog and two small functions don’t need that.

Same reasoning for the newer products. Workflows would earn a place if I had multi-step jobs that must survive crashes and resume. Vectorize and AI Gateway would need a concrete search or routing problem first. Sitting next to Workers AI in the dashboard is not a reason to add them.

How I decide

When a new feature comes up I run through a few questions, and they’re not sophisticated.

Does the page need server code at request time? If not, it stays static on Pages.

Is the data a key and a value? KV. Does it have relationships and queries? D1. Is it a big generated file people download? R2.

Does work need to happen reliably after the response? That would be Queues. Do many requests need one coordinator? Durable Objects. So far the answer to those two has been no.

Is a public form getting abused? Turnstile, with the server-side check.

What I’m really looking for each time is something I can delete. A server, a cron job on a box somewhere, a database connection, a piece of custom infrastructure. When a Cloudflare product lets me remove one of those, I add it. Otherwise I leave it in the sidebar.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about cloudflare: