Production at the Edge
By Flavio Copes
A practical mini-course on running real apps on Cloudflare with Workers, Pages, D1, KV, R2, Queues, Durable Objects, AI, and more.
I built several apps on Cloudflare recently.
Some are static sites. Some have authentication, payments, databases, AI features, scheduled jobs, and user uploads. One is a multi-tenant SaaS with more moving parts than I would put in most applications.
Building all of them taught me something useful: there is no single “Cloudflare stack.”
There is a small set of products. Each solves one specific problem. The hard part is knowing which one to pick, and when to stop adding things.
This is a practical mini-course about that.
We will start with a static site. Then we will add server code, data, files, background jobs, coordination, analytics, security, and AI.
I will show you the architecture I now use for real projects, including the mistakes and production details that do not appear in a hello-world tutorial.
You do not need to use every product in this post.
Most apps should not.
What does “at the edge” mean?
A traditional app usually runs in one region.
You rent a server in Frankfurt, Virginia, or Singapore. Every request travels to that server. The server talks to its database, renders a response, and sends it back.
Cloudflare Workers use a different model.
You deploy code to Cloudflare. When a request arrives, Cloudflare can run that code close to the person making the request.
There is no process you keep alive. There is no port to open. There is no operating system to patch.
Your code receives a standard Request and returns a standard Response.
That is the foundation of the platform.
But “at the edge” does not mean every part of your app exists in every Cloudflare data center.
A Worker can run close to the user. A D1 database still has a primary location. A Durable Object is one authoritative object in one place. An external API might run on another continent.
This distinction matters.
The edge removes a lot of infrastructure. It does not remove distance, consistency, or architecture.
The architectures behind this mini-course
I did not learn this stack by assembling toy examples.
I learned it by building projects with different requirements:
| Project shape | Cloudflare products used |
|---|---|
| Static content sites | Pages, DNS, build cache |
| Static site with a few server routes | Pages, Pages Functions, KV, Turnstile, Workers AI |
| Full-stack Astro app | Workers, static assets, observability |
| App with accounts and payments | Workers, D1, KV, Turnstile, AI Gateway |
| Multi-tenant SaaS | Workers, D1, KV, R2, Queues, Cron Triggers, Durable Objects, Analytics Engine |
| Long-running AI report | Workers, Durable Objects, Workflows, Workers AI |
One large static Astro site runs on Pages. It adds only a few Pages Functions for webhooks and private data retrieval.
Several smaller content-heavy projects stay completely static.
A group of member applications uses Astro server rendering on a Worker, with static assets served directly by Cloudflare.
Two paid developer tools add D1, KV, payments, and AI calls.
A multi-tenant SaaS uses nearly every storage and background-processing product in this post because it has the requirements to justify them.
A long-running AI report app uses a Durable Object as the canonical state of an interview, then starts a Workflow to build the final report in reliable steps.
These projects are useful together because they show a progression.
We can start with almost nothing and add infrastructure only when a feature demands it.
Start with the smallest possible architecture
My default question is not “Which Cloudflare products can I use?”
It is this:
What can I avoid running when a request arrives?
If a page can be generated during the build, I make it static.
If a calculation can run in the browser, I keep it in the browser.
If a small endpoint can do its work without a database, I do not add one.
This gives us a useful order:
- Static HTML, CSS, and JavaScript
- A Worker or Pages Function for dynamic routes
- KV for simple, read-heavy key-value data
- D1 for relational data
- R2 for files
- Queues for reliable background work
- Durable Objects for coordination
- Workflows for long-running multi-step processes
This order is not a rule. It is a defense against unnecessary complexity.
Cloudflare makes it easy to attach a new product. That does not mean the product is free in architectural terms.
Every binding adds data, failure modes, configuration, and something you must understand in production.
Lesson 1: choose Pages or Workers
The first choice is where the application itself runs.
I use two deployment shapes.
Use Pages for a static site
Cloudflare Pages is a natural fit when your build produces a folder of static files.
For an Astro site, the flow is:
Markdown and Astro files
↓
astro build
↓
dist
↓
Cloudflare Pages
The Wrangler configuration can be this small:
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "my-static-site",
"pages_build_output_dir": "./dist",
"compatibility_date": "2026-07-24"
}
You can connect the project to Git and let Cloudflare build after each push.
You can also deploy from the command line:
npx wrangler pages deploy dist --project-name my-static-site
Pages gives you preview deployments, custom domains, HTTPS, redirects, and static asset delivery.
For content sites, directories, documentation, and browser-only tools, this is often all you need.
I like this architecture because there is little request-time code to break.
Use a Worker with static assets for a full-stack app
When the Worker is the application, I deploy the site and server code together.
The configuration points Cloudflare at both:
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "my-app",
"main": "./src/worker.ts",
"compatibility_date": "2026-07-24",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"binding": "ASSETS",
"directory": "./dist"
},
"observability": {
"enabled": true
}
}
Cloudflare serves a matching static asset without running your Worker by default.
If there is no matching asset, the request reaches the Worker.
That is a good default. CSS, images, and generated HTML stay cheap and fast. Dynamic routes run code.
You can change this with run_worker_first, but do it intentionally. Middleware that runs before every asset turns every request into compute.
This is the deployment shape I use for full-stack Astro applications.
The Astro Cloudflare adapter produces the server entrypoint. A small custom Worker can delegate HTTP requests to Astro:
import { handle } from '@astrojs/cloudflare/handler'
export default {
fetch(request, env, ctx) {
return handle(request, env, ctx)
},
}
That file looks unnecessary at first.
It becomes valuable when you add a scheduled handler, a queue consumer, a Workflow, or a Durable Object export to the same Worker.
I explain the basic deployment model in Serving a website with Cloudflare Workers static assets.
Pages Functions are the middle ground
Sometimes the site is static except for two or three routes.
That is the shape of this site.
Pages Functions use files as routes:
functions/purchase.js → /purchase
functions/api/course-access/send.js → /api/course-access/send
You keep the static Pages project and add only the endpoints you need.
This is simpler than turning the whole site into an SSR application.
Be careful with root middleware.
A root functions/_middleware.js can cause every request to invoke a Function. That includes pages, images, stylesheets, and other static assets.
If you use middleware, add a _routes.json file to control which paths execute code.
For example:
{
"version": 1,
"include": ["/purchase", "/api/*"],
"exclude": []
}
The lesson is simple:
Always know which requests run code.
Lesson 2: understand the Worker programming model
A Worker is not an Express server that happens to run somewhere else.
It is a request handler built around web platform APIs.
Here is the smallest useful Worker:
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url)
return Response.json({
path: url.pathname,
method: request.method,
})
},
}
There are three values you will use constantly:
requestcontains the incoming HTTP requestenvcontains bindings, variables, and secretsctxcontrols work connected to the request lifecycle
A more realistic handler looks like this:
interface Env {
DB: D1Database
CACHE: KVNamespace
RESEND_API_KEY: string
}
export default {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
const cached = await env.CACHE.get('homepage')
if (cached) {
return new Response(cached)
}
return new Response('Not cached', { status: 404 })
},
}
Bindings are one of my favorite parts of the platform.
The application does not open a database connection or construct an S3 client. Cloudflare attaches resources to env.
This also makes architecture visible in wrangler.jsonc.
Open one file and you can see what the Worker depends on.
Workers are not normal Node.js servers
Workers use the workerd runtime.
You have fetch, Request, Response, URL, Headers, streams, and Web Crypto.
Cloudflare also supports many Node APIs through compatibility flags.
If a library requires those APIs, add:
{
"compatibility_date": "2026-07-24",
"compatibility_flags": ["nodejs_compat"]
}
This does not turn the Worker into a traditional Node process.
There is still no long-lived server. Memory can disappear between requests. Work that continues after a response needs explicit handling.
My advice is to prefer web APIs when they are enough.
For example, call an email provider using fetch instead of installing a large Node SDK. I use this pattern in Transactional email from Workers with Resend.
Lesson 3: make Wrangler the source of truth
Wrangler is Cloudflare’s command line tool.
I use the dashboard to inspect things. I use Wrangler and repository files to define them.
These are the commands I use most:
npx wrangler dev
npx wrangler types
npx wrangler deploy --dry-run
npx wrangler deploy
npx wrangler tail
wrangler dev runs the application with the Workers runtime and local bindings.
wrangler types generates TypeScript definitions from your configuration.
wrangler deploy --dry-run builds the Worker bundle without making it live.
wrangler deploy creates and deploys a new version.
wrangler tail shows live production logs.
The configuration belongs beside the code because the two change together.
A new D1 binding changes the application type. A new compatibility date can change runtime behavior. A queue consumer changes how failures are retried.
Those are code review concerns, not dashboard trivia.
Pin the compatibility date
The compatibility date controls runtime behavior.
Cloudflare can improve the platform without silently changing old Workers. Your date says which behavior the project expects.
Set it explicitly. Update it intentionally. Test after changing it.
Do not copy a random old date from a tutorial.
Generate binding types
Do not maintain a large Env interface by hand if Wrangler can generate it.
Run:
npx wrangler types
Then let TypeScript tell you when a binding is missing or has the wrong shape.
This catches simple mistakes before deployment.
Lesson 4: separate variables from secrets
Every production app has public configuration and private configuration.
Plain variables can live in wrangler.jsonc:
{
"vars": {
"APP_ENV": "production",
"APP_URL": "https://myapp.com",
"TURNSTILE_SITE_KEY": "public-site-key"
}
}
Secrets must not:
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put TURNSTILE_SECRET_KEY
npx wrangler secret put WEBHOOK_SECRET
Both appear on env at runtime.
The difference is where Cloudflare stores them and whether they appear in your repository.
A Turnstile site key is public. The browser needs it.
A Turnstile secret key is private. Only the Worker uses it.
The word “key” is not enough to tell you which is which.
I cover the full setup in Cloudflare Workers: secrets and environments.
Use separate environments
Larger apps need more than production.
Wrangler environments let you define different Worker names, bindings, variables, and resources:
{
"name": "my-app-dev",
"vars": {
"APP_ENV": "dev"
},
"env": {
"staging": {
"name": "my-app-staging",
"vars": {
"APP_ENV": "staging"
}
},
"production": {
"name": "my-app-production",
"vars": {
"APP_ENV": "production"
}
}
}
}
Deploy one environment with:
npx wrangler deploy --env production
Notice that many binding sections do not inherit into environments.
If production needs D1, KV, R2, Queues, or Cron Triggers, make sure the production environment declares them.
Wrangler warns about missing inherited configuration. Read those warnings.
Lesson 5: choose the right place for data
Cloudflare gives you several storage products.
They overlap just enough to be confusing.
This is my decision table:
| Data | Product |
|---|---|
| Relational application data | D1 |
| Read-heavy key-value data and caches | KV |
| Files and uploads | R2 |
| Strongly consistent per-entity state | Durable Objects |
| High-volume product metrics | Analytics Engine |
Do not pick storage based on which API looks easiest.
Pick it based on the guarantees your data needs.
Use D1 for relational application data
D1 is Cloudflare’s managed database with SQLite semantics.
I use it for users, sessions, reports, subscriptions, settings, products, and other structured data.
Create a database with Wrangler:
npx wrangler d1 create my-app
Then add the binding it returns:
{
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app",
"database_id": "paste-the-generated-id",
"migrations_dir": "migrations"
}
]
}
Start with migrations, even for a tiny app.
Create one:
npx wrangler d1 migrations create my-app create_users
The migration can contain normal SQL:
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
CREATE INDEX users_email_idx ON users(email);
Apply migrations locally:
npx wrangler d1 migrations apply my-app --local
Apply them to the remote database:
npx wrangler d1 migrations apply my-app --remote
The binding gives you prepared statements:
const user = await env.DB
.prepare('SELECT id, email FROM users WHERE email = ?')
.bind(email)
.first()
Always bind values. Do not build SQL by joining user input into a string.
For Astro applications I often add Drizzle for typed queries. The complete setup is in Astro SSR with D1 and Drizzle ORM.
D1 is not a database in every edge location
The Worker can run close to the visitor. The D1 primary database lives in a region.
Writes still have to reach that primary.
Read replication can move reads closer to users, but replicas introduce another consistency question. A recent write might not be visible on every replica immediately.
Do not sell yourself a magical mental model.
Measure the real request path.
If your Worker mostly talks to one database, Smart Placement can be more useful than running the Worker close to the user. It can place compute closer to the backend instead.
Use KV for read-heavy key-value data
KV stores a value under a key.
It is excellent for configuration, cached results, access lists, and data read much more often than it changes.
Add a namespace:
{
"kv_namespaces": [
{
"binding": "CACHE",
"id": "paste-the-namespace-id"
}
]
}
Write a cached value:
await env.CACHE.put(
`report:${reportId}`,
JSON.stringify(report),
{ expirationTtl: 3600 },
)
Read it as JSON:
const report = await env.CACHE.get(
`report:${reportId}`,
'json',
)
The important word is eventual.
KV is eventually consistent. A value written in one location might take time to replace a cached old value elsewhere.
This means KV is a bad place for an exact balance, a unique inventory reservation, or a strict global counter.
I do use KV for simple daily AI usage limits. Those are deliberately soft limits. A small amount of disagreement between locations is acceptable.
I explain that tradeoff in Rate limiting with Cloudflare KV.
If the limit must be exact, use a Durable Object.
Use R2 for files
R2 is object storage.
Use it for images, generated PDFs, exports, backups, and user uploads.
Create a bucket:
npx wrangler r2 bucket create my-app-uploads
Bind it:
{
"r2_buckets": [
{
"binding": "UPLOADS",
"bucket_name": "my-app-uploads"
}
]
}
Store an uploaded file:
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File)) {
return new Response('File required', { status: 400 })
}
await env.UPLOADS.put(`uploads/${crypto.randomUUID()}`, file.stream(), {
httpMetadata: {
contentType: file.type,
},
})
R2 is S3-compatible and does not charge egress fees for data served directly from R2.
That does not make every operation free. You still pay for storage and operations. Design keys, caching, and upload limits carefully.
Do not store large files in D1 because it is the database you already have.
Do not store relational metadata only inside object names because R2 is convenient.
A common pattern is:
- file bytes in R2
- file metadata and ownership in D1
This keeps both systems doing what they are good at.
Use Durable Objects when requests must agree
Durable Objects solve coordination.
A Durable Object has a unique identity. Requests for that identity reach the same authoritative object. It has strongly consistent storage and processes events in order.
This makes Durable Objects useful for:
- exact rate limits
- chat rooms
- collaborative documents
- booking and inventory coordination
- one state machine per user or job
- tenant provisioning locks
In one multi-tenant SaaS, a Durable Object implements precise rate limiting. Another serializes database provisioning and migrations for each tenant.
In another app, one Durable Object owns the full interview state. It makes out-of-order answers and duplicate report generation much easier to prevent.
The important design decision is the name.
One object should represent one unit of coordination.
For a rate limiter, that might be one visitor:
const id = env.RATE_LIMITER.idFromName(visitorId)
const limiter = env.RATE_LIMITER.get(id)
const result = await limiter.check(10, 60)
For a chat application, use one object per room.
For tenant provisioning, use one object per workspace.
Do not send the entire application through one global Durable Object. You would create one global bottleneck.
Lesson 6: design the request path
When a request reaches a full-stack app, I want the path to be obvious.
For a typical authenticated page, it might be:
Browser
↓
Static asset or Worker route
↓
Middleware loads session
↓
Astro page or API handler
↓
D1 query
↓
Response
For a public widget or high-volume API, I might skip Astro:
Browser
↓
Worker
↓
Hono API route
↓
KV, D1, or Analytics Engine
↓
Response
One public widget application uses this split.
Visitor-facing embeds, widget renders, and analytics ingestion go directly to a small Hono app. Dashboard pages go through Astro.
The goal is not to avoid Astro. The goal is to avoid sending a tiny API request through machinery it does not need.
Validate input at the boundary
The Worker is the public boundary of your system.
Validate method, content type, size, and data before doing expensive work.
For example:
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 })
}
const contentType = request.headers.get('content-type') ?? ''
if (!contentType.includes('application/json')) {
return new Response('Expected JSON', { status: 415 })
}
Then parse the body with a schema library such as Zod.
Do this before a database write or AI call.
Return boring errors
Do not send stack traces, provider responses, database errors, or secret names to users.
Log the useful detail and return a plain message:
try {
return await handleRequest(request, env)
} catch (error) {
console.error(JSON.stringify({
message: 'request failed',
path: new URL(request.url).pathname,
error: error instanceof Error ? error.message : String(error),
}))
return new Response('Internal Server Error', { status: 500 })
}
Structured logs are much easier to filter than improvised sentences.
Lesson 7: protect public endpoints
Anything public will be called by scripts.
This is not a theoretical future problem. Put a form online and bots will find it.
Add Turnstile to expensive forms
Turnstile gives the browser a token. Your Worker must verify that token with Cloudflare.
The browser widget alone does nothing.
The complete trust boundary is:
Browser completes Turnstile
↓
Browser sends token with form
↓
Worker calls Siteverify
↓
Worker checks success
↓
Worker performs protected action
A minimal verification function looks like this:
async function verifyTurnstile(
token: string,
ip: string,
secret: string,
): Promise<boolean> {
const response = await fetch(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
secret,
response: token,
remoteip: ip,
}),
},
)
const result = await response.json<{ success: boolean }>()
return result.success
}
Turnstile tokens expire and can only be used once.
Use Cloudflare’s test keys locally. Keep production widgets restricted to production hostnames.
Rate limit before expensive work
The order matters.
For an AI endpoint, I use a path like this:
Validate request
↓
Verify Turnstile
↓
Check per-visitor limit
↓
Check global daily limit
↓
Call the model
Do not call the model and then decide whether the visitor exceeded the limit.
Your spending cap must be checked before the paid operation.
I also keep a global cap. Per-visitor limits do not protect you from a distributed attack.
Verify webhook signatures
Payment and email providers call Workers using webhooks.
Never trust a webhook because it arrived at a secret-looking URL.
Verify its signature using the raw request body.
Then make the operation idempotent.
Providers retry. Some send more than one event for the same business action. Queues can deliver a message more than once. A user can refresh a callback page.
Your code must survive duplicates.
Store the provider event ID in D1 with a unique constraint, or use the provider’s idempotency key support.
This is especially important for payments, credits, emails, and provisioning.
I cover the broader model in Webhooks explained and show a complete payment flow in How I added Polar payments to an Astro app.
Lesson 8: decide how background work should run
Not everything belongs inside the request.
A signup might need to:
- create the account
- send a welcome email
- update analytics
- call a webhook
- notify an administrator
The user should not wait for all of that.
Cloudflare gives us several levels of background execution.
Use waitUntil() for small attached work
ctx.waitUntil() lets work continue after the response is ready:
ctx.waitUntil(
sendAnalyticsEvent(env, event).catch((error) => {
console.error('analytics failed', error)
}),
)
return Response.json({ ok: true })
This is good for short, non-critical work connected to the request.
Do not use it as a replacement for every job system.
If losing the work would be serious, put it on a Queue.
Also do not start a promise and return without awaiting it or passing it to waitUntil().
The Worker may stop once the response is sent.
Use Queues for reliable background jobs
Cloudflare Queues separate the request from the work.
The request sends a small message:
await env.EVENT_QUEUE.send({
type: 'send_welcome_email',
userId,
})
A consumer handles it:
export default {
async queue(batch, env) {
for (const message of batch.messages) {
try {
await processEvent(message.body, env)
message.ack()
} catch (error) {
console.error('queue message failed', error)
message.retry({ delaySeconds: 60 })
}
}
},
}
Configure the producer, consumer, retries, and dead letter queue:
{
"queues": {
"producers": [
{
"binding": "EVENT_QUEUE",
"queue": "my-app-events"
}
],
"consumers": [
{
"queue": "my-app-events",
"max_batch_size": 10,
"max_batch_timeout": 5,
"max_retries": 3,
"dead_letter_queue": "my-app-events-dlq"
}
]
}
}
Catch errors per message.
If one message throws out of the whole batch handler, successful messages can be retried too.
Queues provide at-least-once delivery. The same message can arrive more than once.
Consumers must be idempotent.
In one multi-tenant app, scheduled jobs fan out one queue message per tenant. A slow or broken tenant cannot block maintenance for everyone else. Each tenant gets its own retry.
That is a good Queue pattern: split a large job into independent pieces.
Use Cron Triggers for schedules
Cron Triggers run a Worker’s scheduled handler.
Add schedules in Wrangler:
{
"triggers": {
"crons": ["10 2 * * *", "0 7 * * 1"]
}
}
Handle them in the Worker:
export default {
async scheduled(controller, env, ctx) {
if (controller.cron === '10 2 * * *') {
ctx.waitUntil(runNightlyMaintenance(env))
}
if (controller.cron === '0 7 * * 1') {
ctx.waitUntil(sendWeeklyDigests(env))
}
},
}
For a small job, the scheduled handler can do the work.
For a large job, let the schedule produce Queue messages.
Cron starts the work. Queues distribute and retry it.
Use Workflows for durable multi-step processes
Queues are excellent for independent messages.
Cloudflare Workflows are better when one job has several dependent steps.
For example, an AI report might:
- Load a canonical snapshot
- Call an AI model
- Build a deterministic report
- Save the final result
A Workflow persists progress between steps. If step three fails, Cloudflare can retry from that point instead of repeating every successful step.
The shape looks like this:
import {
WorkflowEntrypoint,
type WorkflowEvent,
type WorkflowStep,
} from 'cloudflare:workers'
export class ReportWorkflow extends WorkflowEntrypoint<Env, Params> {
async run(
event: WorkflowEvent<Params>,
step: WorkflowStep,
): Promise<void> {
const snapshot = await step.do(
'load report data',
async () => loadSnapshot(event.payload.reportId),
)
const summary = await step.do(
'generate summary',
{ retries: { limit: 2, delay: '2 seconds' } },
async () => generateSummary(this.env, snapshot),
)
await step.do(
'save report',
async () => saveReport(snapshot, summary),
)
}
}
Use a Workflow when the process has a story and progress.
Use a Queue when you have messages to process.
That distinction keeps both designs simpler.
Lesson 9: add AI without losing control of cost
Cloudflare gives us two different AI products that I use for different reasons.
Workers AI runs models through a binding
Workers AI runs models on Cloudflare’s infrastructure.
Add the binding:
{
"ai": {
"binding": "AI"
}
}
Then call a model:
const result = await env.AI.run('@cf/openai/gpt-oss-20b', {
messages: [
{
role: 'system',
content: 'Write one concise summary using only supplied facts.',
},
{
role: 'user',
content: JSON.stringify(facts),
},
],
})
There is no provider API key in your Worker.
The model name is still a production dependency. Models can be added, changed, or retired.
Keep the model in one adapter module. Do not scatter model IDs across routes.
I also keep a deterministic fallback.
If an AI feature can produce a useful result without AI, the application degrades much better when a model fails or a budget cap is reached.
AI Gateway sits in front of external providers
AI Gateway is useful when I call OpenAI or another external provider.
It gives me one place to inspect requests, tokens, latency, errors, and cost. It can also add caching, rate limits, and routing.
The application keeps using the provider API. The base URL points through Cloudflare:
const response = await fetch(env.OPENAI_BASE_URL, {
method: 'POST',
headers: {
'authorization': `Bearer ${env.OPENAI_API_KEY}`,
'cf-aig-authorization': `Bearer ${env.CF_AIG_TOKEN}`,
'content-type': 'application/json',
},
body: JSON.stringify(payload),
})
Use an authenticated Gateway in production.
The provider key and Gateway token are separate secrets. One authorizes the model call. The other protects your Gateway.
Put a hard cap before every AI call
AI pricing is usage-based. A bug can become a bill.
For public AI tools, I use:
- a per-visitor daily limit
- a global daily limit
- a cheap model by default
- maximum input length
- maximum output tokens
- a timeout
- a deterministic fallback
Check the global cap before the model call.
Do not rely only on provider dashboards or a warning email. Those tell you about spending after it happened.
Lesson 10: build observability before you need it
An application running on someone else’s infrastructure can feel invisible.
Do not wait for the first production error to add logs.
Enable Workers observability:
{
"observability": {
"enabled": true,
"logs": {
"enabled": true,
"head_sampling_rate": 1
},
"traces": {
"enabled": true,
"head_sampling_rate": 0.01
}
}
}
The right sampling rate depends on traffic and cost.
For a small new app, logging every request can be reasonable. For a high-volume endpoint, it can be noisy and expensive.
Use structured logs:
console.log(JSON.stringify({
message: 'report created',
reportId,
userId,
durationMs,
}))
Do not log access tokens, cookies, full webhook bodies, email content, or private AI prompts.
You can inspect live logs with:
npx wrangler tail
Traces show where time went across fetch calls and Cloudflare bindings.
This is especially useful when the Worker is fast but the request is slow. The delay might be D1, an external API, or an AI provider.
The full setup is in Cloudflare Workers observability: logs and traces.
Analytics Engine is not your application database
Analytics Engine stores high-volume event data for analysis.
Use it for product metrics such as:
- endpoint usage
- widget views
- feature usage per tenant
- request duration
- usage-based billing measurements
Write a data point:
env.ANALYTICS.writeDataPoint({
blobs: [route, method, status],
doubles: [durationMs, 1],
indexes: [workspaceId],
})
Then query the dataset later using SQL.
Do not use Analytics Engine for user accounts or anything you need to read back during the request as canonical state.
D1 stores application truth. Analytics Engine stores measurements.
Lesson 11: test the runtime you deploy
An app can work in astro dev and fail in a Worker.
The runtimes are not identical.
Node packages may import unsupported APIs. Bindings may be missing. A library may assume a writable filesystem. Environment handling may differ.
My minimum verification loop is:
npm run check
npm test
npm run build
npx wrangler deploy --dry-run
For Worker integration tests, use Cloudflare’s Vitest pool so code runs in the Workers runtime.
For browser flows, use Playwright against a local Worker preview.
For example:
npm run build
npx wrangler dev
Then test the real routes, headers, redirects, cookies, and bindings.
Local and remote resources are different
Wrangler can emulate D1, KV, R2, Queues, and Durable Objects locally.
This is fast and safe.
It is not proof that the remote configuration is correct.
Before launch, verify:
- remote bindings exist
- remote migrations ran
- production secrets exist
- custom domains point to the right Worker
- production Turnstile hostnames are allowed
- webhook URLs use the production domain
- scheduled triggers appear in the deployed Worker
Most deployment failures are configuration mismatches, not difficult code bugs.
Keep local state persistent
When local D1 and Durable Object data should survive restarts, use a persistent Wrangler state directory:
npx wrangler dev --persist-to .wrangler/state
Keep .wrangler out of Git.
The data is useful locally. It is not a deployment artifact.
Lesson 12: deploy without treating deployment as the finish line
A successful wrangler deploy means Cloudflare accepted the bundle.
It does not mean the application works.
After deployment, run a small smoke test:
curl -I https://myapp.com
curl https://myapp.com/api/health
Check one authenticated flow, one database write, and one external integration.
If the app receives webhooks, send a provider test event.
If it has a Queue, verify the consumer processed a message.
If it has a Cron Trigger, confirm the schedule exists before waiting until tomorrow.
Use Custom Domains when the Worker is the origin
If the Worker is your application, configure it as a custom domain:
{
"routes": [
{
"pattern": "myapp.com",
"custom_domain": true
},
{
"pattern": "www.myapp.com",
"custom_domain": true
}
]
}
A route such as example.com/* is more appropriate when the Worker sits in front of another origin.
Do not confuse those two roles.
Know what a rollback does
Cloudflare keeps Worker versions and deployments. You can roll code back quickly.
But a Worker rollback does not roll back D1 migrations, KV writes, R2 objects, or Durable Object storage.
Code and data have separate histories.
This is why database changes should be backward compatible during deployment.
A safe sequence is often:
- Add the new column or table
- Deploy code that can use both old and new shapes
- Backfill data if needed
- Switch reads to the new shape
- Remove old data in a later deployment
Avoid a migration that makes the currently deployed Worker fail immediately.
Gradual deployment does not remove version skew
Workers can split traffic between versions.
That is useful, but two versions may run at the same time. A user can hit the old version and then the new version. Service bindings can connect applications on different rollout stages.
Keep contracts compatible during the rollout.
The same principle applies to assets. New HTML should not assume an old JavaScript file disappeared instantly from every cache.
Lesson 13: understand the failure modes
The platform is easier to operate when you know how each part fails.
A Worker can disappear after the response
Await required work or use ctx.waitUntil().
Use Queues when work needs reliable delivery and retries.
KV can return an older value
Use KV only when eventual consistency is acceptable.
Use D1 or a Durable Object when the latest write must be authoritative.
A Queue can deliver twice
Use idempotency keys and unique database constraints.
Treat duplicate delivery as normal.
A Cron Trigger can start too much work
Do not process every tenant in one large scheduled handler.
Fan out to a Queue so each unit gets its own retry and failure boundary.
A Workflow step can run again
Keep side effects idempotent.
Give external API calls an idempotency key when possible.
A Durable Object can become a bottleneck
Choose one object per unit of coordination.
Do not create one global object for unrelated users.
An AI model can change or disappear
Keep the model behind one adapter. Limit inputs and outputs. Provide a fallback.
Check the current model catalog before deployment.
A webhook can be valid and still be a duplicate
Signature verification proves who sent the request.
It does not prove you have not processed it before.
You need both verification and idempotency.
A static asset can accidentally invoke a Worker
Check Pages _routes.json and Workers static asset routing.
The cheapest request is often the one that never executes your code.
A practical architecture for a new app
Suppose we are building a small SaaS that creates reports.
Users sign in, answer questions, generate a report, pay to unlock it, and download a PDF.
I would start with this architecture:
┌──────────────┐
│ Turnstile │
└──────┬───────┘
│ verify
Browser ── static assets ──> Cloudflare Worker
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
D1 KV R2
users/reports soft limits generated PDFs
│
▼
Queue ──────> email/webhooks
│
▼
Workflow ────> AI report generation
│
▼
Workers AI or
AI Gateway
The first version might need only the Worker and D1.
Add KV when repeated work needs caching or soft limits.
Add R2 when the first PDF exists.
Add a Queue when email or webhooks make the request slow or unreliable.
Add a Workflow when report generation becomes a multi-step job.
Add a Durable Object only if the report interview needs one authoritative state machine, or if generation must start exactly once.
This is how I want you to think about the platform.
Not as a shopping list.
As a set of upgrades triggered by real requirements.
My production checklist
Before launching a Cloudflare application, I check these things.
Runtime and routing
- The compatibility date is explicit and recent
nodejs_compatis enabled only when needed- Static assets do not invoke the Worker unnecessarily
- Pages Functions have a narrow
_routes.json - The production custom domain points to the right Worker
- Root and
wwwbehavior is intentional
Configuration
- Public variables are in
wrangler.jsonc - Secrets are stored with Wrangler, never committed
- Staging and production use different resources
- Generated binding types are current
- Environment-specific bindings are repeated where Wrangler requires them
Data
- D1 migrations are committed and tested locally
- Remote migrations ran before dependent code went live
- Database changes are backward compatible
- KV is not used for strict consistency
- R2 uploads have size and content-type limits
- Durable Objects are sharded by a real unit of coordination
Security
- Public input is validated before expensive work
- Turnstile is verified on the server
- Rate limits run before AI and third-party API calls
- Webhook signatures use the raw body
- Webhook and Queue handlers are idempotent
- Logs do not contain secrets or private user data
Background work
- Required work is awaited
- Short optional work uses
waitUntil() - Reliable work uses a Queue
- Failed messages go to a dead letter queue
- Scheduled jobs fan out instead of doing unbounded work
- Workflow steps can safely retry
Operations
- Observability is enabled
- Logs are structured and useful
- Trace sampling matches traffic and budget
- A production smoke test exists
- Rollback is understood, including what happens to data
- AI features have global spending caps and fallbacks
You do not need a large operations team to run this stack.
You do need to make these decisions explicit.
What I would not add yet
Cloudflare has many more products.
There is Vectorize for embeddings, Hyperdrive for existing Postgres and MySQL databases, Browser Rendering, Images, Email Workers, Containers, service bindings, and more.
They are useful when the application needs them.
I do not add Vectorize because an app has one AI feature.
I do not add R2 because an app has a logo.
I do not add a Queue for one short database write.
I do not add a Durable Object because the name sounds powerful.
The architecture should explain itself.
If you cannot say what failure or requirement a product solves, remove it.
Where to go next
This mini-course gives you the full production map.
The individual tutorials go deeper into each part:
- Cloudflare Workers: your first serverless function
- Wrangler: the Cloudflare Workers command line tool
- Serving a website with Cloudflare Workers static assets
- Cloudflare Workers: secrets and environments
- Cloudflare D1: a SQL database for your Workers
- Cloudflare KV: a key-value store for your Workers
- Cloudflare R2: object storage without egress fees
- Cloudflare Queues: run work in the background
- Cloudflare Durable Objects: state that lives in one place
- Cloudflare Cron Triggers: run a Worker on a schedule
- Cloudflare Turnstile: stop bots without annoying CAPTCHAs
- Cloudflare Workers observability: logs and traces
You can also use The Cloudflare Guide as an index of everything I wrote about the platform.
Start with a static site.
Add one Worker route.
Attach one binding when the feature needs it.
Then ship.
That is how production at the edge becomes boring, which is exactly what we want.
Related posts about cloudflare: