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.

When you open the dashboard, it can feel like entering a hardware store. Everything looks useful. Everything seems to connect to something else. It is tempting to pick a product first, then search for a problem it can solve.

I try to do the opposite.

I start with a real problem. Then I use the smallest Cloudflare product that removes that problem.

This post is not a complete guide to the platform. I already have a Cloudflare guide that links to the individual tutorials.

This is the personal version.

I want to show you what I use in production, why I use it, what I like, and what caught me by surprise.

I will also cover products I learned but do not reach for automatically. That part matters. A platform is easier to use when you know what not to add.

Cloudflare DNS is the foundation

The first Cloudflare product I use is also the least exciting one: DNS.

DNS connects a domain name to the service that answers requests for it. If you own yourapp.com, DNS tells browsers where yourapp.com lives.

Cloudflare manages the DNS for this site.

That was already true before I moved the site hosting to Cloudflare Pages. When I made the move, the final cutover was mostly a matter of connecting the existing domain to the Pages project.

This is one reason I like keeping DNS separate in my head from hosting.

Your domain is the stable part. Hosting can change.

If you move from one platform to another, you update a record. You do not need to move the whole identity of the site at the same time.

The Cloudflare proxy adds another layer. Requests can pass through Cloudflare before reaching the origin. This gives you HTTPS, caching, traffic controls, and protection at the edge.

The best part is that DNS becomes the control panel for the public side of the project.

The gotcha is that the orange proxy switch changes how traffic flows.

If your origin also redirects HTTP to HTTPS, or it expects a different TLS mode, you can create a redirect loop. I wrote about fixing too many redirects after enabling the Cloudflare proxy because this is easy to hit.

My rule is simple: change one layer at a time.

First make DNS resolve correctly. Then enable the proxy. Then verify HTTPS and redirects with curl.

For example:

curl -I https://flaviocopes.com

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

Cloudflare Pages runs this site

This site is built with Astro.

Almost every page is static. Astro generates HTML files, images, RSS, the sitemap, tag pages, and all the blog posts during the build.

Cloudflare Pages is a very good fit for that shape.

The build command is:

npm run build

The output goes into dist.

The relevant part of wrangler.jsonc is small:

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

Cloudflare builds the project after a Git push and publishes the generated files.

What problem does Pages solve for me?

It removes the web server.

I do not configure Nginx. I do not copy files to a VPS. I do not renew certificates. I do not keep a process alive. I give Cloudflare a folder of static files and it serves them.

The best part is how boring production becomes.

A static file is a wonderful deployment unit. There is little to break at request time because most work already happened during the build.

The main gotcha was configuration becoming the source of truth.

Once I added pages_build_output_dir to wrangler.jsonc, the repository configuration controlled important Pages settings. A dashboard value that had previously selected a newer Node version no longer saved me.

Cloudflare’s build image used Node 18.17.1 by default. The version of Astro used by the project needed a newer Node release.

The fix was to add a .node-version file:

24.15.0

This is a tiny file with a big job. It makes the required runtime explicit.

My advice is to pin the build runtime in the repository. Do not rely on a dashboard value you might forget exists.

Pages Functions handle the small dynamic part

Static sites often have a few things that cannot be static.

The course purchase and access system on this site has two important server-side routes.

One receives purchase webhooks. The other helps past students retrieve course access links.

They live in the functions directory:

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

Cloudflare Pages turns those files into routes:

/purchase
/api/course-access/send

This is the part of Pages I appreciate most. I can keep the site static and add a few explicit server-side endpoints without turning the entire project into a server-rendered application.

The purchase function verifies a signed webhook, records access, subscribes the buyer to the correct list, and sends email.

The retrieval function verifies a form submission, looks up course data, and sends the result.

The site also has a few small API routes for interactive tools. Those routes use the same boundary: the pages stay static, while only the request that genuinely needs server code reaches a Function.

These are normal web functions. They receive a request and return a response.

The best part is the boundary.

Static content stays static. Dynamic work only happens on the few Function routes that need it.

The biggest gotcha is accidentally making that boundary too wide.

For a while, the project had a root Pages middleware used for content negotiation. A root middleware can cause every request to pass through a Worker, including requests for images, CSS, and other static assets.

That means a useful little feature can turn every page view into a metered function invocation.

I removed it.

Eliminating the request was better than optimizing it.

If I add root middleware again, I will also add a _routes.json file that limits which paths reach the function.

This is a general Cloudflare lesson: always know which requests execute code.

Workers are the common programming model

Pages Functions use the same basic runtime model as Cloudflare Workers.

A Worker receives standard web objects such as Request, Response, Headers, and URL.

A minimal Worker looks like this:

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

I like this model because there is not much framework-specific machinery.

If you know the browser Fetch API, much of the code feels familiar.

Bindings are passed through env. A database, a KV namespace, and secrets all appear there.

For example:

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

The best part is composability. The function stays small while the platform services are attached through configuration.

The gotcha is assuming the runtime is Node.js.

Workers support many Node APIs, especially with the nodejs_compat compatibility flag, but the runtime is not a normal long-running Node server.

This site’s purchase webhook verifies signatures using node:crypto. The project has this configuration:

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

The compatibility date matters. It pins runtime behavior and controls which features are available.

Do not copy an old configuration without understanding its date and flags.

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

Wrangler is how I operate the platform

The Cloudflare dashboard is useful, but I prefer repeatable project configuration.

That is where Wrangler comes in.

Wrangler is Cloudflare’s command line tool. I use it to run code locally, create resources, set secrets, inspect deployments, and work with bindings.

The command pattern is consistent.

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

The best part is that wrangler.jsonc documents the infrastructure beside the code.

I can open one file and see the Pages output directory, compatibility settings, public variables, and KV bindings.

The gotcha is that not everything belongs in that file.

A public Turnstile site key can live in configuration because it is embedded in the page anyway.

A private API key must not.

Secrets belong in Cloudflare’s secret store.

This distinction sounds obvious, but names can be misleading. A value called a “key” is not automatically secret. The Turnstile site key is public. The Turnstile secret key is private.

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

KV stores course access data and usage counters

Cloudflare KV is the first storage product I use on this site.

KV stores a value under a key.

The course retrieval flow uses an email address as the lookup key. The stored value contains the course access entries connected to that address.

A second namespace stores short-lived usage counters for the AI-assisted tools. Those values expire after two days. KV is a good fit because each counter has one known key and no relational query.

The binding is configured like this:

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

The function can read JSON directly:

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

What problem does KV solve here?

It gives the function a fast lookup without requiring a relational database.

The question is simple: “What value belongs to this key?”

There are no joins. There is no reporting query. There is no need to sort thousands of rows.

The best part is the small API.

You can understand the important operations in a minute: get, put, delete, and list.

The gotcha is eventual consistency.

KV is designed for frequent reads distributed across the world. A write might not become visible everywhere immediately.

That is fine for many caches, preferences, access lists, and configuration values.

It is not fine for a bank balance or an exact global counter.

I also learned another Workers-specific lesson while building the purchase flow: await important network work before returning the response.

A traditional server process might keep running after a handler returns. A Worker isolate may be stopped. If the work matters, await it or explicitly attach it to the request lifecycle.

You can learn the KV basics in Cloudflare KV: a key-value store for your Workers.

D1 is my choice for structured application data

This site does not use D1 for its blog posts. The posts are files, and that is exactly how I want them.

I do use D1 in a production application where the data is relational.

That project has users, saved reports, provider information, products, and settings. Those things belong in tables. They need indexes, constraints, filters, and migrations.

D1 is Cloudflare’s SQLite database.

The code uses a binding such as env.DB, while Wrangler manages the database and migrations.

A query can be very small:

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

The best part is using SQL without managing a database server.

I do not provision a machine, open a network port, manage a connection pool, or schedule operating system upgrades.

The gotcha is believing “SQLite” means “use it exactly like a local SQLite file.”

D1 is a managed distributed service with its own limits and operational model. You should understand its transaction behavior, query limits, and migration workflow.

I use migrations from the start. Even a tiny application grows, and manually changing production tables does not scale.

I explain the workflow in Cloudflare D1: a SQL database for your Workers.

My decision between KV and D1 is usually easy.

If I have a key and a value, I consider KV.

If I have entities and relationships, I use D1.

Turnstile protects public forms

Public forms attract bots.

The course access form on this site uses Cloudflare Turnstile before it sends anything.

Turnstile has two parts.

The browser renders the widget with a public site key. The function verifies the generated token with a private secret key.

The browser side is small:

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

The server then sends the token to Cloudflare’s siteverify endpoint.

The best part is the low friction for real people.

Most users do not need to identify traffic lights, buses, bridges, or bicycles. The check often happens without a puzzle.

The gotcha is thinking the widget alone provides protection.

It does not.

A bot can send a request directly to your endpoint and skip the page. The server must verify the token before doing the protected work.

This is the rule I want you to remember:

The browser widget collects a token. The server verification creates trust.

I show the complete flow in Cloudflare Turnstile: stop bots without annoying CAPTCHAs.

The Pages build cache made a large site practical

Build caching sounds like an implementation detail.

On a site with around 1,800 posts, it becomes a product feature.

This project generates Open Graph cards for courses, tools, topic pages, and other important pages during the Astro build. Regenerating the same cards on every deployment wastes time.

Cloudflare Pages can preserve specific cache directories between builds.

For Astro, it keeps node_modules/.astro when the build cache is enabled.

The image generator originally stored its cache in another directory. Cloudflare discarded it after every build, so every image was recreated.

I moved the cache inside Astro’s preserved directory:

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

The result was concrete.

A cold build took about 45 seconds. A warm build took about 12 seconds.

The best part is avoiding repeated work without adding another service.

The gotcha is that Pages does not preserve any directory you choose. It has an allow-list of framework cache locations.

If a tool writes elsewhere, move its cache under the directory Cloudflare restores.

I documented the details in How the Cloudflare Pages build cache works.

R2 stores the large downloads

The site now uses R2 for books, generated course downloads, and software ZIP files.

Those files do not belong in the Pages build. They are large, they have their own generation and upload workflow, and a download should not require rebuilding the site.

The public files live behind downloads.flaviocopes.com. The repository keeps a small manifest that records the expected files and metadata, while the generated PDF, EPUB, and ZIP files stay outside the Astro output.

The best part is separating publishing from delivery.

A normal site build validates the manifest and creates links. Uploading a new download is a separate explicit command. The website can change without uploading every book again, and a large download does not make the Git repository larger.

The gotcha is that object storage adds another deployment boundary.

A link can be correct in HTML while its object is missing from the bucket. I validate the manifest during the production build and verify uploaded files separately. The custom domain also gives the files one stable public origin even if the storage implementation changes later.

I still keep normal article images in the repository. They are part of the pages and benefit from the static build. R2 solves the large-download problem; it does not replace every file on the site.

A Cron Trigger publishes scheduled posts

Future-dated posts are excluded from the static build until their publication time.

That creates a small operational problem. Time can pass, but a static site does not rebuild itself.

I run a tiny scheduled Worker that calls a Cloudflare Pages deploy hook after the normal publication slots. The Worker registers both UTC offsets for Rome and checks the local wall-clock time before triggering the build, so daylight-saving changes do not require a manual cron edit.

There is also a GitHub Actions schedule as a backup.

The best part is that the Worker does not publish content directly. It only asks Pages to run the same build used after a normal Git push. The content rules stay in Astro, in one place.

The gotcha is build queue state. A deploy hook can return without creating another useful build when one is already queued, and a stuck active build can block every deployment behind it. Scheduled systems still need an observable failure path.

Workers AI powers a small tool

The site uses a Workers AI binding for an AI-assisted tool.

The browser never receives a model credential. A Pages Function verifies Turnstile, checks hard daily counters in KV, and only then calls a small model through the binding.

This is a deliberately narrow use of AI. The model helps with one bounded task. It does not control the site or receive a general tool API.

The best part is that the model binding follows the same env pattern as KV and D1.

The gotcha is cost and abuse. A cheap model is still not free when a public endpoint can be called repeatedly. I use both global and per-client daily caps, and I check them before the model call.

Products I know but do not add by default

I wrote tutorials about Queues and Durable Objects.

They are useful products. They are not part of this site’s core request path.

That is worth saying because technical writing can create a strange impression. If someone writes about a tool, readers can assume the tool is used everywhere.

That is not how I work.

I would use Queues when a request needs to hand reliable background work to another process.

The current purchase flow is small enough to perform its required work directly. A queue would become interesting if the workflow grew, if retries needed stronger isolation, or if processing took too long.

I would use Durable Objects when many requests must coordinate through one authoritative stateful object.

Examples include a chat room, a live collaboration session, or a precise rate limiter. A blog and two small functions do not need that coordination.

The same reasoning applies to 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. I do not add them because they sit next to Workers AI in the dashboard.

The best architecture is not the one using the most platform products.

It is the one with the fewest moving parts that still handles the real requirements.

How I decide what to use

When I look at a new feature, I ask a short list of questions.

Does the page need server code at request time?

If not, I keep it static.

Does the data have a natural key and a single value?

I consider KV.

Does the data have relationships and queries?

I consider D1.

Is the data a large generated file or download?

I consider R2.

Does work need reliable processing after the request?

I consider Queues.

Do many requests need one consistent coordinator?

I consider Durable Objects.

Is a public form being abused?

I add Turnstile, including server verification.

Can the whole site be generated before the request arrives?

I use Pages and let static files do the job.

This decision process is not sophisticated. That is why it works.

Cloudflare’s products are strongest when each has one clear responsibility.

Pages serves the site. Functions handle the few dynamic routes. KV provides simple lookups. D1 stores structured app data. R2 serves large downloads. Turnstile protects forms. Workers AI powers bounded tools. A Cron Trigger starts scheduled builds. Wrangler ties configuration and operations together.

I do not use every product.

I use the products that let me delete a server, delete a cron job, delete a database connection, or delete custom infrastructure.

That is the real value for me.

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

~~~

Related posts about cloudflare: