Where the code runs
When a Worker is the wrong tool
Workers run your code, not your existing software. A Worker cannot host Plex, Home Assistant, Proxmox or a Postgres database — anything already running as a process on a machine still needs a tunnel or a mesh. Reach for a Worker when you are writing the thing: an API, a webhook receiver, a redirect service, a small site, a piece of logic that sits in front of something else.
Build and deploy
-
Check prerequisites
- Node.js 16.17.0 or later. Use a current LTS in practice —
node --version. - A Cloudflare account. A domain is not required to start; you get a
workers.devaddress for free.
- Node.js 16.17.0 or later. Use a current LTS in practice —
-
Create the project
npm create cloudflare@latest -- my-first-workerThis is C3, Cloudflare's scaffolding tool. Answer the prompts:
Prompt Answer Template Hello World exampleType Worker onlyLanguage JavaScriptorTypeScriptGit YesDeploy now No— deploy deliberately in step 05You end up with:
File Purpose wrangler.jsonc Configuration — name, entry point, routes, bindings src/index.js Your Worker package.json Dependencies and scripts jsonc, not tomlNew projects are scaffolded with
wrangler.jsonc. Older projects and most tutorials you'll find usewrangler.toml, which is still fully supported — the keys are the same, only the syntax differs. Don't keep both in one project. -
Read the handler
export default { async fetch(request, env, ctx) { return new Response("Hello World!"); }, };Every request to your Worker calls
fetch(). The three arguments are the whole interface:Argument What it is request The incoming Request— URL, method, headers, bodyenv Your bindings: environment variables, secrets, KV, D1, R2 ctx Execution context — notably ctx.waitUntil()for work that should outlive the responseSomething marginally more useful — routing on the path and returning JSON:
export default { async fetch(request, env, ctx) { const { pathname } = new URL(request.url); if (pathname === "/health") { return Response.json({ ok: true, region: request.cf?.colo }); } if (pathname.startsWith("/echo")) { return Response.json({ method: request.method, headers: Object.fromEntries(request.headers), }); } return new Response("Not found", { status: 404 }); }, };request.cfcarries Cloudflare-specific metadata — the colo that served the request, the visitor's country, TLS details. It exists in production but not always in local development. -
Run it locally
npx wrangler devServes on
http://localhost:8787, reloading as you save. This runs workerd — the same runtime Cloudflare uses in production — not a Node emulation, so behaviour matches deployment closely. Pressbto open a browser,xto quit. -
Deploy
npx wrangler deployThe first run opens a browser to authorise Wrangler against your account, and prompts you to choose a
workers.devsubdomain if you have not got one. When it finishes, your Worker is live at:https://my-first-worker.<your-subdomain>.workers.devThat URL is public immediately. There is no staging step and no approval — deploy means deployed.
-
Put it on your own domain
Cloudflare dashboard › Workers & Pages › your Worker › Settings › Domains & Routes › Add
Custom Domain Route Matches an exact hostname a pattern, e.g. example.com/api/*DNS created for you you manage it Certificate issued automatically you manage it Use when the Worker is the site the Worker fronts part of an existing site Or declare it in
wrangler.jsoncand let deploys manage it:{ "name": "my-first-worker", "main": "src/index.js", "compatibility_date": "2026-09-01", "routes": [ { "pattern": "api.example.com", "custom_domain": true } ] }A Custom Domain needs a free hostnameYou cannot attach one to a hostname that already has a
CNAMErecord — including a hostname already routed to a Cloudflare Tunnel. Pick a different subdomain, or remove the existing record first. Deleting a Custom Domain later also leaves its certificate behind for you to clean up manually. -
Add configuration and secrets
Non-sensitive values go in the config file and are visible to anyone with repo access:
{ "vars": { "ENVIRONMENT": "production", "LOG_LEVEL": "info" } }Secrets never go in the file. Push them separately:
npx wrangler secret put API_KEY # prompts, does not echo npx wrangler secret listBoth arrive on
env:if (request.headers.get("x-api-key") !== env.API_KEY) { return new Response("Unauthorized", { status: 401 }); }For local development, put the same keys in
.dev.vars— and confirm it is in.gitignore, which the C3 template handles for you. -
Add storage, if you need it
Workers are stateless between requests. State lives in a binding:
npx wrangler kv namespace create MY_KVWrangler prints an id to paste into your config:
{ "kv_namespaces": [ { "binding": "MY_KV", "id": "a1b2c3..." } ] }await env.MY_KV.put("visits", count); const visits = await env.MY_KV.get("visits");Binding Suits KV Key-value, read-heavy, eventually consistent. Config, caches, feature flags. D1 SQLite with real SQL. Relational data, modest scale. R2 Object storage, S3-compatible, no egress fees. Files and images. Durable Objects Strongly consistent, single-instance coordination. Sessions, counters, realtime. -
Protect it, if it is not meant to be public
A Worker on a Custom Domain is an ordinary hostname in your zone, so it sits behind Cloudflare Access exactly like a tunnelled app: create a self-hosted application for that hostname, attach an
Allowpolicy, done.Disable workers.dev or Access is decorativeAdding a Custom Domain does not retire the
<worker>.<subdomain>.workers.devaddress. It stays live, it is guessable, and it is not in your zone — so no Access policy applies to it. Anyone who finds it walks straight past your protection. Turn it off under Settings › Domains & Routes, or in config:{ "workers_dev": false }For machine callers rather than people, prefer a shared secret checked in the handler (step 07) or an Access service token — not IP allow-listing, which is brittle at the edge.
Day-to-day
Commands worth knowing
npx wrangler dev # local, hot reload, real runtime
npx wrangler deploy # ship it
npx wrangler tail # live production logs
npx wrangler versions list # what is deployed
npx wrangler rollback # revert to the previous version
npx wrangler secret put NAME # add or replace a secret
npx wrangler kv namespace list # existing namespaces
compatibility_date is not decoration
The compatibility_date in your config pins runtime behaviour. Cloudflare ships changes to
the Workers runtime continuously; your Worker keeps the semantics of the date you set, so a deploy
months later does not silently change how your code behaves. Raise it deliberately, and test when you do.
Common failures
| Symptom | Cause |
|---|---|
| Error 1101 | Your code threw. npx wrangler tail shows the exception with a stack. |
| Error 1102 | CPU time exceeded — usually an unbounded loop or very heavy synchronous work. |
| Custom Domain rejected | A DNS record already exists for that hostname. Remove it, or choose another subdomain. |
| Works locally, fails deployed | Often a missing secret — .dev.vars is local only. Check wrangler secret list. |
env.X undefined | Binding declared in the dashboard but not in wrangler.jsonc, so the next deploy dropped it. The config file is the source of truth. |
| Node module not found | Workers are not Node. Add "compatibility_flags": ["nodejs_compat"], or use a Web-standard API instead. |