Cloudflare Workers · create, deploy, route, protect

Running code with no server to run it on

A Worker is JavaScript that Cloudflare executes at the edge. There is no machine to install, no connector to keep healthy, and nothing on your LAN to protect — which makes it the opposite of everything a tunnel exists to solve.

Where the code runs

WITH A TUNNEL Browser anywhere Cloudflare passes through Your machine code runs here tunnel WITH A WORKER Browser anywhere Cloudflare code runs here KV · D1 · R2 optional binding no machine to own
The highlighted box is where your code executes. A tunnel carries a request past Cloudflare to hardware you maintain; a Worker ends the journey at Cloudflare, reaching outward only if you bind it to storage. Everything a tunnel needs — a host, a connector, a patched OS — simply has no equivalent here.

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

  1. 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.dev address for free.
  2. Create the project
    npm create cloudflare@latest -- my-first-worker

    This is C3, Cloudflare's scaffolding tool. Answer the prompts:

    PromptAnswer
    TemplateHello World example
    TypeWorker only
    LanguageJavaScript or TypeScript
    GitYes
    Deploy nowNo — deploy deliberately in step 05

    You end up with:

    FilePurpose
    wrangler.jsoncConfiguration — name, entry point, routes, bindings
    src/index.jsYour Worker
    package.jsonDependencies and scripts
    jsonc, not toml

    New projects are scaffolded with wrangler.jsonc. Older projects and most tutorials you'll find use wrangler.toml, which is still fully supported — the keys are the same, only the syntax differs. Don't keep both in one project.

  3. 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:

    ArgumentWhat it is
    requestThe incoming Request — URL, method, headers, body
    envYour bindings: environment variables, secrets, KV, D1, R2
    ctxExecution context — notably ctx.waitUntil() for work that should outlive the response

    Something 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.cf carries 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.

  4. Run it locally
    npx wrangler dev

    Serves 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. Press b to open a browser, x to quit.

  5. Deploy
    npx wrangler deploy

    The first run opens a browser to authorise Wrangler against your account, and prompts you to choose a workers.dev subdomain if you have not got one. When it finishes, your Worker is live at:

    https://my-first-worker.<your-subdomain>.workers.dev

    That URL is public immediately. There is no staging step and no approval — deploy means deployed.

  6. Put it on your own domain
    Cloudflare dashboard › Workers & Pages › your Worker › Settings › Domains & Routes › Add
    Custom DomainRoute
    Matchesan exact hostnamea pattern, e.g. example.com/api/*
    DNScreated for youyou manage it
    Certificateissued automaticallyyou manage it
    Use whenthe Worker is the sitethe Worker fronts part of an existing site

    Or declare it in wrangler.jsonc and 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 hostname

    You cannot attach one to a hostname that already has a CNAME record — 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.

  7. 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 list

    Both 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.

  8. Add storage, if you need it

    Workers are stateless between requests. State lives in a binding:

    npx wrangler kv namespace create MY_KV

    Wrangler 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");
    BindingSuits
    KVKey-value, read-heavy, eventually consistent. Config, caches, feature flags.
    D1SQLite with real SQL. Relational data, modest scale.
    R2Object storage, S3-compatible, no egress fees. Files and images.
    Durable ObjectsStrongly consistent, single-instance coordination. Sessions, counters, realtime.
  9. 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 Allow policy, done.

    Disable workers.dev or Access is decorative

    Adding a Custom Domain does not retire the <worker>.<subdomain>.workers.dev address. 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

SymptomCause
Error 1101Your code threw. npx wrangler tail shows the exception with a stack.
Error 1102CPU 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 undefinedBinding 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.