Skip to main content

Cloudflare Workers: a local API, storage, and production preparation

Cloudflare Workers receives HTTP requests, runs code, and returns responses. It suits small APIs, request forwarding, authorization checks, and dynamic features alongside a static website. You write the request-handling logic; Cloudflare operates the runtime. You do not need to configure a Linux server first.

This guide starts with a local TypeScript API: check its health, send a name, and receive a greeting. An optional local database query follows, then storage choices, domains, security, and costs. The base example requires no login, deployment, cloud resource, or secret; scaffolding downloads development dependencies. You should be comfortable editing files, using a terminal, and reading basic JavaScript.

1. Understand where the code runs

Workers uses V8 isolates: separate JavaScript execution environments within a shared runtime, rather than a virtual machine for each application. workerd is the Workers runtime and also powers local Wrangler development. When an HTTP request arrives, the platform calls your exported fetch handler, which returns a response. See the execution model and local development documentation.

The handler's inputs and output have distinct roles:

  • request is a Web Request, containing the URL, method, headers, and body.
  • The returned Web Response contains a status code, headers, and body.
  • env exposes configuration values and bindings. A binding is a resource interface supplied by the platform, such as env.DB, not merely a database address string.
  • Optional ctx provides request lifecycle helpers, including short work after a response. It does not turn a task into a permanent background process.
  • The handler's name, fetch, means it handles incoming requests. Calling the global fetch(url) inside it sends another HTTP request.

This model does not require application-owned listen() calls. Module-level variables may be reused across requests or disappear when an isolate is evicted. Do not use them to store the current user, orders, or a counter that must persist reliably. Even where filesystem APIs are available, do not treat them as a server's persistent disk.

Compatibility dates and Node.js

compatibility_date selects a runtime behavior baseline. It is neither a Node version nor a deployment date. Follow the official recommendation to use the current date for a new project; review changes and rerun tests when advancing it. It does not pin npm dependencies: those still need a lockfile. Compatibility dates

As consulted on 2026-09-12, the Node.js compatibility documentation says compatibility dates from 2026-08-04 enable the behavior of nodejs_compat and nodejs_compat_v2 by default. New configurations do not need to repeat these flags. Dates from 2024-09-23 through 2026-08-03 require opting in with nodejs_compat. Support still consists of the documented Node APIs and polyfills; some modules are importable stubs whose methods throw. Installing an npm package does not establish that it works. Evaluate native extensions, operating-system services, and permanent-process assumptions separately. The API below uses only Web APIs and does not depend on Node compatibility.

2. Workers, static hosting, containers, or a VPS?

These are architectural starting points, not a cost or latency ranking. The official Pages page currently recommends Workers for new projects; this does not require migrating existing Pages sites.

NeedConsider firstWhat to account for
Small HTTP API, forwarding, authorization at the request entry pointWorkersRuntime compatibility, CPU and memory budgets, downstream storage
HTML, CSS, images, and a small adjacent APIWorkers Static AssetsAsset routing and Worker invocation rules; do not assume every file request passes through authorization code
An existing Pages site with a reliable build workflowKeep Pages; evaluate migration for a concrete needA preference for Workers on new projects does not require migrating an existing site
An existing container image or container-shaped web serviceCloud RunContainer startup, service configuration, dependencies, and scaling; the execution unit is not a V8 isolate
Full operating-system control, custom daemons, and server softwareVPSSystem updates, networking, process management, backups, and recovery

For a form validator that returns JSON, start by evaluating a Worker. For an existing container service with native dependencies, Cloud Run may be the more direct fit. Both execution placement and database placement affect latency; Workers is not inherently faster or cheaper than a VPS.

3. Create a local-only project

Prepare the tools

Install a Node.js release in Current, Active LTS, or Maintenance LTS, npm, and curl. Follow the Wrangler installation policy rather than a stale numeric Node minimum from an older getting-started page. Node runs the development tools; it does not make the deployed Worker a Node server.

In a new working directory, use the official C3 scaffolding workflow:

npm create cloudflare@latest -- workers-local-api
cd workers-local-api

C3 generates a project; Wrangler is the command-line tool for developing, configuring, and publishing Workers. Choose a minimal Worker example and TypeScript, and decline deployment. Prompt wording may change. Stop if asked to log in or authorize access rather than treating authorization as part of local practice. Keep C3's compatible dependencies, lockfile, and TypeScript configuration; there is no need for a second Wrangler upgrade immediately afterward.

Configuration

Set wrangler.jsonc to the following. JSONC allows comments and is the recommended configuration format. APP_ENV is an ordinary configuration value. Wrangler configuration

{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "workers-local-api",
"main": "src/index.ts",
"compatibility_date": "2026-09-12",
"vars": {
"APP_ENV": "local"
}
}

The date is this tutorial's behavior baseline, not a Wrangler release number. If your local tool warns that it does not support the date, do not ignore the warning or claim to have tested that baseline. Check the installed version and its update guidance, then deliberately adjust the tool or date and retest.

Complete request handler

Replace src/index.ts with the following code. Env and ExportedHandler come from the Workers types generated in the next step; do not install a separate, mismatched set of runtime types.

function json(value: unknown, status = 200, extra: HeadersInit = {}) {
const headers = new Headers(extra);
headers.set("content-type", "application/json; charset=utf-8");
headers.set("cache-control", "no-store");
return new Response(JSON.stringify(value), { status, headers });
}

function error(status: number, code: string, extra: HeadersInit = {}) {
return json({ error: code }, status, extra);
}

async function readSmallBody(request: Request): Promise<string | null> {
if (!request.body) return "";
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > 4096) {
await reader.cancel();
return null;
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const path = new URL(request.url).pathname;
try {
if (path === "/health") {
if (request.method !== "GET") {
return error(405, "method_not_allowed", { Allow: "GET" });
}
return json({ ok: true, environment: env.APP_ENV });
}
if (path !== "/api/greet") return error(404, "not_found");
if (request.method !== "POST") {
return error(405, "method_not_allowed", { Allow: "POST" });
}
const type = request.headers.get("content-type")
?.split(";", 1)[0].trim().toLowerCase();
if (type !== "application/json") {
return error(415, "unsupported_media_type");
}
const text = await readSmallBody(request);
if (text === null) return error(413, "payload_too_large");
let payload: unknown;
try {
payload = JSON.parse(text);
} catch {
return error(400, "invalid_json");
}
if (typeof payload !== "object" || payload === null ||
Array.isArray(payload) || !("name" in payload) ||
typeof payload.name !== "string") {
return error(400, "invalid_name");
}
const name = payload.name.trim();
if (name.length < 1 || name.length > 80) {
return error(400, "invalid_name");
}
return json({ message: `Hello, ${name}!` });
} catch {
console.error("request_failed", { method: request.method });
return error(500, "internal_error");
}
},
} satisfies ExportedHandler<Env>;

The endpoint accepts a JSON object. After trimming surrounding whitespace, name must contain 1–80 JavaScript string code units; extra properties are ignored. Code units are not visible characters: some emoji occupy two units. The body is limited to 4096 bytes actually read, rather than trusting the client's Content-Length. This bounds buffered input but does not, by itself, defend against slow clients or abusive traffic.

A wrong method on a known path returns 405 with Allow; an unknown path returns 404. Error responses do not disclose exception details, and all responses disable caching. The example is method-strict: it does not implicitly support HEAD or OPTIONS and does not add permissive CORS headers. It performs no sensitive operation. Turning it into an anonymous database-write endpoint would not make it production-ready.

Generate types and start development

Wrangler generates types from configuration, bindings, compatibility date, and flags. Run:

npx wrangler types
npx tsc --noEmit
npx wrangler dev --local

Check C3's tsconfig.json: compilerOptions.types should include ./worker-configuration.d.ts, preserving other necessary entries. If Env or ExportedHandler cannot be found, check this setting and the generated file instead of masking the problem with any. Regenerate after configuration changes. TypeScript guide

The default local address is http://localhost:8787; use the address actually printed by the terminal. If the port is occupied, run npx wrangler dev --local --port 8788 and update the URLs below.

Check successful and failing requests with curl

Leave the development process running. In a second terminal in the project directory, run:

npx wrangler types --check
npx tsc --noEmit
curl -i http://localhost:8787/health
curl -i -X POST http://localhost:8787/api/greet \
-H 'Content-Type: application/json' --data '{"name":"Ada"}'
curl -i -X POST http://localhost:8787/api/greet \
-H 'Content-Type: application/json' --data '{"name":" "}'
curl -i -X POST http://localhost:8787/api/greet \
-H 'Content-Type: application/json' --data '{'
curl -i http://localhost:8787/api/greet
curl -i -X POST http://localhost:8787/api/greet --data 'name=Ada'
curl -i http://localhost:8787/missing
node -e 'process.stdout.write(JSON.stringify({name:"a".repeat(4097)}))' | \
curl -i -X POST http://localhost:8787/api/greet \
-H 'Content-Type: application/json' --data-binary @-

The table gives the results the code should produce for comparison with your local run. Type-checking alone does not establish correct HTTP behavior.

Request orderStatusBody and required header
Health200{"ok":true,"environment":"local"}
Valid name200{"message":"Hello, Ada!"}
Blank name400{"error":"invalid_name"}
Malformed JSON400{"error":"invalid_json"}
GET greeting405{"error":"method_not_allowed"}, Allow: POST
Form media type415{"error":"unsupported_media_type"}
Unknown path404{"error":"not_found"}
More than 4096 bytes413{"error":"payload_too_large"}

For a refused connection, first check that Wrangler is running and the port matches. A 415 often means the JSON header is missing; for 400, inspect the JSON syntax and fields. Not every failure is a platform authorization problem.

4. Local code does not guarantee local resources

The local development documentation distinguishes code location from resource location. Ordinary local development runs workerd, and bindings can use local simulated resources. Setting remote: true can connect local code to a real resource; wrangler dev --remote uploads code for remote execution.

This tutorial forces local bindings with --local and configures no remote resources. That is not a network sandbox: an external fetch() in your code can still call a real API, send data, or incur costs. Always-remote capabilities such as Workers AI are outside this exercise.

Put ordinary configuration in vars, not credentials. If local secrets become necessary, use .dev.vars or .env beside the configuration and ensure Git ignores them. They are plaintext local files. .dev.vars.<env> replaces the base .dev.vars, whereas the .env family merges by precedence; these are different loading rules. Secrets documentation

In particular, npx wrangler secret put KEY creates a version and deploys immediately. npx wrangler versions secret put KEY creates a version without deploying it. Both mutate cloud state and neither is a local configuration step. This example needs no secrets.

5. Choose bindings by the data requirement

Binding names must be valid JavaScript identifiers: DB in configuration becomes env.DB in code. A binding grants access to a resource; it does not decide whether the current user may read a particular record. Binding configuration

ResourceSuitable useCommon misuse
KVRead-heavy configuration or cached data that can be staleEventually consistent: propagation between locations may take 60 seconds or more; misses are cached too. Not a lock or reliable atomic counter
D1SQLite-based relational data, SQL queries, and constraintsDesign tables and queries for the database; check capacity and concurrency limits separately
R2Objects accessed by key, such as images, attachments, and backupsObject storage is neither a relational database nor a shared filesystem
Durable ObjectsChat rooms, per-entity coordination, and processing with strongly consistent storageSend related work to the same object identity; it does not automatically serialize the whole application
QueuesAsynchronous work requiring retriesAt-least-once delivery by default; duplicates can occur. Do not assume exactly-once or ordered processing
Service bindingsOne Worker calling another Worker's methods or HTTP handlerNo public URL is required; still design caller permissions and the target service's public exposure

“Eventually consistent” means readers in different locations may temporarily see different values after a write. KV's cacheTtl can extend stale visibility. A reliable concurrent inventory decrement therefore cannot be implemented as “read KV, subtract one, write back.”

Queue consumers need idempotency: processing the same event repeatedly must not charge twice or create duplicate business records. Assign a unique event ID and commit the database effect together with a uniqueness-constrained record of that ID in one transaction. For an external payment service, use its supported idempotency key. A separate “check whether processed, then act” sequence still has a race.

6. Optional: read a local note with D1

This section is for readers who already have the identity of a dedicated tutorial database. Creating the database is a cloud operation; this section creates no resources and does not borrow a production database. Without that prerequisite, keep the greeting API as your working example. Do not invent a database UUID to make the configuration look complete.

Replace the configuration with this complete alternative and substitute the dedicated tutorial database's actual ID for the placeholder. It is not runnable unchanged. Commands with --local use a separate local database and do not initialize remote data. D1 getting started and local development

{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "workers-local-api",
"main": "src/index.ts",
"compatibility_date": "2026-09-12",
"vars": { "APP_ENV": "local" },
"d1_databases": [
{
"binding": "DB",
"database_name": "workers-tutorial-db",
"database_id": "REPLACE_WITH_DEDICATED_DATABASE_ID"
}
]
}

Create schema.sql at the project root:

CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL
);
INSERT OR IGNORE INTO notes (id, title) VALUES (1, 'Local D1 note');

Stop the original development process, then initialize and query locally:

npx wrangler d1 execute workers-tutorial-db --local --file=./schema.sql
npx wrangler d1 execute workers-tutorial-db --local --command='SELECT id, title FROM notes;'
npx wrangler types

The query should show ID 1 with title Local D1 note. The generated Env now includes DB. Inside the original handler's outer try, before if (path !== "/api/greet"), insert:

if (path === "/api/note") {
if (request.method !== "GET") {
return error(405, "method_not_allowed", { Allow: "GET" });
}
const id = new URL(request.url).searchParams.get("id");
if (!id || !/^[1-9]\d*$/.test(id) || !Number.isSafeInteger(Number(id))) {
return error(400, "invalid_id");
}
const result = await env.DB
.prepare("SELECT id, title FROM notes WHERE id = ?")
.bind(Number(id))
.run();
return json({ notes: result.results });
}

The SQL ? is a parameter placeholder. .bind() keeps values separate from SQL structure instead of concatenating input into a query. This route only reads local demonstration data; add authentication and record-level authorization before using it for private user notes.

npx wrangler types --check
npx tsc --noEmit
npx wrangler dev --local

Check in a second terminal:

curl -i 'http://localhost:8787/api/note?id=1'
curl -i 'http://localhost:8787/api/note?id=2'
curl -i 'http://localhost:8787/api/note?id=bad'

In order, expect 200 with {"notes":[{"id":1,"title":"Local D1 note"}]}, 200 with {"notes":[]}, and 400 with {"error":"invalid_id"}. no such table usually means the schema was not applied in the same project and local state location. Check the database name and --local; do not switch to remote execution as an experiment. Local data persists for later development but is not uploaded automatically. Local success is not proof of a production migration.

7. Before publishing: inspect routing and environments separately

Stop here if you only want the local exercise. The following section explains routing and environments, then offers an optional cloud deployment that signs in, exposes an API publicly, and may incur charges. Database creation, secret uploads, and domain changes are not part of this minimal deployment.

The routing documentation distinguishes three entry points:

Entry pointPurpose
workers.devA Cloudflare-provided subdomain for getting started; official guidance recommends a route or custom domain for business-critical production services
Custom DomainMakes the Worker the application origin on an owned domain or subdomain within a Cloudflare zone
RouteRuns a Worker for matching traffic within a zone, commonly in front of an existing origin

A zone is a DNS area managed by Cloudflare; an origin is the service that actually supplies application content. Before taking over a hostname, inspect existing traffic, DNS, and fallback behavior. These entry points are not interchangeable addresses. If the Worker performs security checks, failing open to the origin on quota exhaustion may bypass them. Choose failure behavior according to the data risk. Limits and exceeded-limit behavior

Configure staging and production variables and resources explicitly. Wrangler's vars and bindings are not automatically inherited by named environments: an environment name alone does not isolate a database. APP_ENV: local is only a label, not a resource-location switch. Environment configuration

You can first add local env.staging variables and, if needed, a dedicated D1 mapping, then check with npx wrangler dev --local --env staging. Before every resource command, confirm the account, environment, resource ID, and local/remote options. Never point staging at the production database merely because the code is identical.

Optional: publish a dedicated practice Worker

Read the pricing section below and confirm that your account permits this deployment. Use the basic handler from section 3 without the D1 route inserted in section 6; if you completed that extension, first restore section 3's complete src/index.ts. This public example only returns health information and greetings. It stores no user data and provides no private business functionality.

Replace wrangler.jsonc with the complete configuration below, changing YOUR_UNUSED_WORKER_NAME to an unused practice name in your account. Do not reuse a live Worker's name. There are no database, domain-route, or secret bindings:

{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "YOUR_UNUSED_WORKER_NAME",
"main": "src/index.ts",
"compatibility_date": "2026-09-12",
"vars": { "APP_ENV": "local" },
"env": {
"tutorial": {
"workers_dev": true,
"vars": { "APP_ENV": "tutorial" }
}
}
}

The named-environment rules give this Worker the top-level name plus -tutorial. workers_dev creates a public entry point, not access control. Generate types, check the code, and try bundling first:

npx wrangler types
npx tsc --noEmit
npx wrangler deploy --env tutorial --dry-run

--dry-run compiles without publishing to live servers. It neither validates account permissions nor proves remote behavior, and it does not promise zero local filesystem writes. Once it succeeds, login starts browser-based OAuth authorization. Confirm the intended account, then deploy:

npx wrangler login
npx wrangler deploy --env tutorial

If your login has access to multiple accounts, confirm the destination in the CLI prompts. Use the actual HTTPS URL printed by deployment rather than guessing the subdomain. Replace the placeholder below before checking it:

export WORKER_URL="https://YOUR_DEPLOYED_WORKER_HOST"
curl -i "$WORKER_URL/health"
curl -i -X POST "$WORKER_URL/api/greet" \
-H 'Content-Type: application/json' --data '{"name":"Ada"}'

Expect HTTP 200 with {"ok":true,"environment":"tutorial"} and {"message":"Hello, Ada!"}, respectively. The tutorial label helps reveal a request reaching the wrong environment, but does not replace checking the URL. After editing code, repeat local checks before running the same environment-specific deployment command. To observe real requests, use npx wrangler tail --env tutorial; this connects to cloud logs and may sample under high traffic. Ctrl-C stops log viewing, not the deployed Worker.

8. Security, logs, and recovery

Before exposing real functionality

  • Identity and permissions: validate a session or token, then check whether that user can perform this operation on this resource. Knowing a URL, binding name, or object ID is not authorization.
  • CORS: this is a browser cross-origin policy, not authentication. If cross-origin access is needed, allow only intended origins and handle OPTIONS explicitly. When returning a validated origin dynamically, add Vary: Origin; do not combine a wildcard origin with credentials. curl does not enforce browser CORS checks.
  • SSRF: do not accept an arbitrary user URL and blindly fetch() it. Control server-side request forgery through a fixed upstream or strict allowlist, scheme restrictions, and redirect checks. The example makes no outbound requests.
  • Secrets and logs: keep credentials out of source, vars, URLs, client bundles, errors, and logs. Log only what troubleshooting requires; avoid bodies, Cookies, tokens, and personal data.
  • Abuse and budgets: limit input and request rates, and set application quotas for expensive operations. A platform CPU limit is not a complete spending control.

What to observe

Local console.error output appears in the development terminal. Remote options include Workers Logs and real-time logs. Check configuration, sampling, retention, and pricing for the chosen option; do not assume every request is retained permanently.

After publishing, you should be able to answer: which route has more errors? Is time spent computing or waiting for the database? Is a queue repeatedly retrying? Is a storage binding failing? Use stable route labels, status codes, and correlation IDs instead of logging full URLs that may contain personal data. Diagnose a generic client-facing 500 through controlled logs, not by returning a stack trace.

Rollback and cleanup

Confirm the full target Worker name first; section 7's name includes the -tutorial suffix. After replacing the placeholder with that verified name, npx wrangler rollback --name YOUR_VERIFIED_WORKER_NAME is a remote command that changes live traffic immediately: the selected version receives 100% of traffic, with the last 100 published versions eligible. Deleted bound resources are not restored, and Durable Object class lifecycle changes can prevent rollback. Rollback documentation

Keep an identifiable previous version and compatible data structures, verify them in a dedicated environment, then decide whether to roll back production. Code rollback does not undo database writes; schema changes require a separate migration and backup-recovery plan.

Finish local work with Ctrl-C to stop Wrangler. Keeping the tutorial project and local database is fine. To reset data, first confirm the persistence directory and ownership, then remove only that exercise's state; avoid broad recursive deletion commands. The local-only chapters create no cloud resources to delete. If you completed section 7's deployment, first run npx wrangler delete --env tutorial --dry-run from the directory retaining that practice configuration. Only after confirming that the Worker and associated resources belong to the exercise should you run npx wrangler delete --env tutorial and inspect its confirmation prompt. The delete command may also remove associated platform resources; never use it to clean up a project that reuses production bindings.

9. Costs and limits: CPU time is not waiting time

These Workers HTTP allowances and prices were consulted on 2026-09-12. They are not a promise that an entire application runs free. Recheck pricing and limits before a production estimate.

ItemDocumented value and meaning
Free requests100,000 requests per day
Free CPU10 ms CPU per invocation, not a 10 ms end-to-end response deadline
Paid base chargeMinimum 5 USD per account per month
Paid Standard requests10 million per month included; 0.30 USD per additional million
Paid Standard CPU30 million CPU-ms per month included; 0.02 USD per additional million CPU-ms
Memory128 MB per isolate, potentially shared by concurrent requests; not 128 MB per request
Paid HTTP CPU limitDefaults to 30 seconds, configurable up to 5 minutes; not a target consumption per request

CPU time measures active computation. Waiting for fetch, KV, or a database generally does not count as CPU. A request waiting 200 ms for a database need not use 200 CPU-ms, but the request and database operation may still be billed. Workers pricing has no duration charge; other products have their own bills.

HTTP elapsed time is tied to the connection lifecycle and is not a basis for permanent background tasks. ctx.waitUntil() extends work by at most 30 seconds after a response or disconnection. Use an appropriate queue or task mechanism for reliable background work. HTTP, queue, and scheduled-trigger limits are not interchangeable. Execution limits

Static asset requests are normally free and unlimited, but the pricing page qualifies this: with Workers Caching enabled, cache-served requests incur request billing, including static assets; cache misses or bypasses also consume CPU. Do not generalize free asset serving to every caching and routing configuration.

Estimate requests, CPU distributions, asset caching configuration, D1/KV/R2 operations and storage, queue retries, and logs separately. For error 1102, investigate CPU or memory exhaustion; 1027 concerns the Free daily request allowance. Identify the cause before optimizing or changing plans. A plan upgrade does not replace fixing infinite loops, oversized buffers, or repeated retries.

For terminal and server foundations, see Tools & Workflows. If the application ultimately needs full operating-system control, continue with Personal VPS Fundamentals.

Explore connectionsOpen network