Skip to content

Deploy a Small Stateful Web App

A small stateful web app is one file of Worker code plus static asset files, one URL, persistent server-side data, and an optional password gate. It uses Cloudflare Worker + D1 with no build step. Setup is done once by a human; every subsequent deploy runs autonomously through the Cloudflare MCP server.

When to use

Use this pattern when you need a web app with persistent data that an agent can deploy end-to-end. The app stays small, is mostly static, and can be password-protected.

The pattern

The process has two phases: one-time setup (human) and per-deploy steps (agent).

Setup (one-time, human):

  1. Create a Cloudflare account
  2. Add the Code Mode MCP connector to Claude
  3. Claim your .workers.dev subdomain

Deploy (six steps, agent-executed via the Code Mode MCP):

  1. Create the D1 database
  2. Initialize the schema
  3. Start an asset upload session
  4. Upload the asset bodies
  5. Upload the Worker
  6. Enable the .workers.dev subdomain for this script

After the first deploy, use the steps in the Modifying section to update the app without repeating setup.

Setup

Perform this setup once. Skip it if you have already done it.

  1. Sign up at dash.cloudflare.com. The free tier is sufficient. Save the account ID from the URL; every API call below uses it.

  2. In Claude, go to Settings → Connectors → Add custom connector:

    • Name: Cloudflare Code Mode
    • URL: https://mcp.cloudflare.com/mcp

    Authorize with Full access (Read-only blocks deployment). The connector exposes two tools, search and execute, that cover the entire Cloudflare API. This is different from bindings.mcp.cloudflare.com (the "Cloudflare Developer Platform" connector), which can't deploy Worker code.

    The execute tool takes a single code parameter: a string containing a bare async () => { ... } arrow function. It evaluates that string in an isolated sandbox where cloudflare.request() and accountId are pre-injected, calls the function automatically, and returns the JSON response. Every code block in the Deploy and Modifying sections below is the exact string to pass as the code argument — for example:

    execute(code="async () => {\n  return cloudflare.request({...});\n}")
  3. Claim the account-level .workers.dev subdomain (one-time, account-wide). Pass as a string to execute's code parameter:

    js
    async () => {
      return cloudflare.request({
        method: "PUT",
        path: `/accounts/${accountId}/workers/subdomain`,
        body: { subdomain: "<your-handle>" },
      });
    }

    Wait 30–60 seconds for global DNS propagation. After that, every Worker on the account is reachable instantly.

A paid Claude plan (Pro, Max, Team, Enterprise) is required to add the custom connector.

Deploy

The agent executes these six steps autonomously. For each step, pass the code block as a string to the execute tool's code parameter. Order matters because later steps reference IDs from earlier ones.

1. Create the D1 database. Pass as a string to execute's code parameter:

js
async () => {
  return cloudflare.request({
    method: "POST",
    path: `/accounts/${accountId}/d1/database`,
    body: { name: "my-app-db" },
  });
}

Save result.uuid from the response. The Worker binding needs it.

2. Initialize the schema. Pass as a string to execute's code parameter:

js
async () => {
  const dbId = "uuid-from-step-1";
  return cloudflare.request({
    method: "POST",
    path: `/accounts/${accountId}/d1/database/${dbId}/query`,
    body: {
      sql: `CREATE TABLE IF NOT EXISTS items (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        created_at TEXT DEFAULT CURRENT_TIMESTAMP
      )`,
    },
  });
}

Use IF NOT EXISTS so the call is idempotent on re-deploys.

3. Start an asset upload session.

Upload static assets through a manifest-first flow. Hash each file with SHA-256, send the manifest, and Cloudflare returns a list of which files it actually needs (deduplicated against any prior upload). Pass as a string to execute's code parameter:

js
async () => {
  // Compute SHA-256 for each asset
  const assets = {
    "/login.html": "<sha256 hex>",
    "/app.html": "<sha256 hex>",
    "/app.js": "<sha256 hex>",
  };

  const manifest = Object.fromEntries(
    Object.entries(assets).map(([path, hash]) => [
      path,
      { hash, size: 0 }, // size is informational; hash is what dedupes
    ])
  );

  return cloudflare.request({
    method: "POST",
    path: `/accounts/${accountId}/workers/scripts/my-app/assets-upload-session`,
    body: { manifest },
  });
}

The response includes result.jwt (the upload session token) and result.buckets (arrays of file hashes to upload, grouped for parallel uploads).

4. Upload the asset bodies.

For each hash in the buckets returned by step 3, POST the file content. Cloudflare allows batching multiple files per request. Pass as a string to execute's code parameter:

js
async () => {
  const sessionJwt = "jwt-from-step-3";
  const files = {
    "<sha256 hex of login.html>": "<base64 of login.html>",
    "<sha256 hex of app.html>": "<base64 of app.html>",
    "<sha256 hex of app.js>": "<base64 of app.js>",
  };

  const boundary = `----Assets${Date.now()}`;
  const parts = Object.entries(files).flatMap(([hash, b64]) => [
    `--${boundary}`,
    `Content-Disposition: form-data; name="${hash}"; filename="${hash}"`,
    `Content-Type: application/octet-stream`,
    `Content-Transfer-Encoding: base64`,
    ``,
    b64,
  ]);
  parts.push(`--${boundary}--`, ``);

  return cloudflare.request({
    method: "POST",
    path: `/accounts/${accountId}/workers/assets/upload?base64=true`,
    body: parts.join("\r\n"),
    contentType: `multipart/form-data; boundary=${boundary}`,
    rawBody: true,
    headers: { authorization: `Bearer ${sessionJwt}` },
  });
}

The response includes result.jwt, the completion token. Save it because the Worker upload needs it.

5. Upload the Worker.

Send a multipart upload of the Worker module plus metadata declaring bindings and the asset completion token. Pass as a string to execute's code parameter:

js
async () => {
  const dbId = "uuid-from-step-1";
  const assetsJwt = "completion-jwt-from-step-4";

  const workerCode = `/* see Worker template below */`;

  const metadata = {
    main_module: "worker.js",
    compatibility_date: "2025-01-01",
    assets: {
      jwt: assetsJwt,
      config: {
        html_handling: "auto-trailing-slash",
        not_found_handling: "single-page-application",
      },
    },
    bindings: [
      { name: "DB", type: "d1", database_id: dbId },
      { name: "APP_PASSWORD", type: "secret_text", text: "<password>" },
      {
        name: "SESSION_SECRET",
        type: "secret_text",
        text: "<32+ random bytes>",
      },
      { name: "ASSETS", type: "assets" },
    ],
  };

  const boundary = `----CodeMode${Date.now()}`;
  const body = [
    `--${boundary}`,
    `Content-Disposition: form-data; name="metadata"`,
    `Content-Type: application/json`,
    ``,
    JSON.stringify(metadata),
    `--${boundary}`,
    `Content-Disposition: form-data; name="worker.js"; filename="worker.js"`,
    `Content-Type: application/javascript+module`,
    ``,
    workerCode,
    `--${boundary}--`,
    ``,
  ].join("\r\n");

  return cloudflare.request({
    method: "PUT",
    path: `/accounts/${accountId}/workers/scripts/my-app`,
    body,
    contentType: `multipart/form-data; boundary=${boundary}`,
    rawBody: true,
  });
}

rawBody: true is mandatory. Without it the MCP runtime tries to JSON-stringify the body and the multipart parse fails. The main_module value must match the filename= field.

6. Enable the .workers.dev subdomain for this script. Pass as a string to execute's code parameter:

js
async () => {
  return cloudflare.request({
    method: "POST",
    path: `/accounts/${accountId}/workers/scripts/my-app/subdomain`,
    body: { enabled: true, previews_enabled: true },
  });
}

The app is live at https://my-app.<your-handle>.workers.dev.

Worker template

The Worker handles auth, sessions, and the API. Static files (HTML, JS, CSS) live as separate assets that the Worker serves through env.ASSETS.

worker.js: auth gate, session crypto, API endpoints, and asset delegation:

js
// --- Session module (Mozilla client-sessions pattern, Web Crypto)
const enc = new TextEncoder();
const dec = new TextDecoder();

function b64urlEncode(buf) {
  const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
  let chars = "";
  for (const byte of bytes) chars += String.fromCharCode(byte);
  return btoa(chars).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function b64urlDecode(str) {
  const normalized = str.replace(/-/g, "+").replace(/_/g, "/");
  const pad = normalized.length % 4 === 0 ?
    "" :
    "=".repeat(4 - (normalized.length % 4));
  const raw = atob(normalized + pad);
  return new Uint8Array([...raw].map(chr => chr.charCodeAt(0)));
}

async function deriveKeys(secret) {
  const ikm = await crypto.subtle.importKey(
    "raw", enc.encode(secret), "HKDF", false, ["deriveBits"],
  );
  const salt = new Uint8Array(32);
  const encBits = await crypto.subtle.deriveBits(
    { name: "HKDF", hash: "SHA-256", salt, info: enc.encode("enc") },
    ikm, 256,
  );
  const sigBits = await crypto.subtle.deriveBits(
    { name: "HKDF", hash: "SHA-256", salt, info: enc.encode("sig") },
    ikm, 256,
  );
  const encKey = await crypto.subtle.importKey(
    "raw", encBits, { name: "AES-CBC" }, false, ["encrypt", "decrypt"],
  );
  const sigKey = await crypto.subtle.importKey(
    "raw", sigBits,
    { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"],
  );
  return { encKey, sigKey };
}

async function createSession(data, opts) {
  const { encKey, sigKey } = await deriveKeys(opts.secret);
  const exp = Date.now() + opts.durationMs;
  const payload = enc.encode(JSON.stringify({ ...data, exp }));
  const ivBytes = crypto.getRandomValues(new Uint8Array(16));
  const ciphertext = await crypto.subtle.encrypt(
    { name: "AES-CBC", iv: ivBytes }, encKey, payload,
  );
  const ivP = b64urlEncode(ivBytes);
  const ctP = b64urlEncode(ciphertext);
  const sig = await crypto.subtle.sign(
    "HMAC", sigKey, enc.encode(`${ivP}.${ctP}`),
  );
  return [
    `${opts.cookieName}=${ivP}.${ctP}.${b64urlEncode(sig)}`,
    "HttpOnly", "Path=/", "SameSite=Lax", "Secure",
    `Max-Age=${Math.floor(opts.durationMs / 1000)}`,
  ].join("; ");
}

function clearSessionCookie(opts) {
  const attrs = "HttpOnly; Path=/; SameSite=Lax; Secure; Max-Age=0";
  return `${opts.cookieName}=; ${attrs}`;
}

async function readSession(cookieHeader, opts) {
  if (!cookieHeader) return null;
  const cookies = Object.fromEntries(
    cookieHeader.split(";").map(entry => {
      const [key, ...rest] = entry.trim().split("=");
      return [key, rest.join("=")];
    })
  );
  const raw = cookies[opts.cookieName];
  if (!raw) return null;
  const parts = raw.split(".");
  if (parts.length !== 3) return null;
  const [ivP, ctP, sigP] = parts;
  try {
    const { encKey, sigKey } = await deriveKeys(opts.secret);
    const ok = await crypto.subtle.verify(
      "HMAC", sigKey, b64urlDecode(sigP), enc.encode(`${ivP}.${ctP}`));
    if (!ok) return null;
    const plain = await crypto.subtle.decrypt(
      { name: "AES-CBC", iv: b64urlDecode(ivP) }, encKey, b64urlDecode(ctP));
    const data = JSON.parse(dec.decode(plain));
    if (typeof data.exp !== "number" || data.exp < Date.now()) return null;
    return data;
  } catch {
    return null;
  }
}

// --- App
const SESSION_OPTS_BASE = {
  cookieName: "app_session",
  durationMs: 30 * 24 * 60 * 60 * 1000,
};

function json(data, status = 200, extra = {}) {
  return new Response(JSON.stringify(data), {
    status,
    headers: { "content-type": "application/json", ...extra },
  });
}

function redirect(location, extra = {}) {
  return new Response(null, { status: 302, headers: { location, ...extra } });
}

async function isAuthed(req, opts) {
  const session = await readSession(req.headers.get("cookie"), opts);
  return !!session?.ok;
}

export default {
  async fetch(req, env) {
    const opts = { ...SESSION_OPTS_BASE, secret: env.SESSION_SECRET };
    const url = new URL(req.url);
    const path = url.pathname;
    
    // Login submit
    if (path === "/login" && req.method === "POST") {
      const form = await req.formData();
      const password = String(form.get("password") || "");
      if (password !== env.APP_PASSWORD) {
        const url = new URL("/login.html?error=1", req.url);
        return redirect(url.pathname + url.search);
      }
      const cookie = await createSession({ ok: true }, opts);
      return redirect("/", { "set-cookie": cookie });
    }
    
    // Logout
    if (path === "/logout" && req.method === "POST") {
      return redirect("/login.html", {
        "set-cookie": clearSessionCookie(opts),
      });
    }
    
    // Public asset: login page
    if (path === "/login.html" || path === "/login") {
      return env.ASSETS.fetch(
        new Request(new URL("/login.html", req.url), req),
      );
    }
    
    // Auth gate — everything else requires a valid session
    if (!(await isAuthed(req, opts))) {
      if (path.startsWith("/api/")) {
        return json({ error: "unauthorized" }, 401);
      }
      return redirect("/login.html");
    }
    
    // API
    if (path === "/api/items" && req.method === "GET") {
      const rows = await env.DB
        .prepare("SELECT * FROM items ORDER BY id DESC")
        .all();
      return json(rows.results);
    }
    if (path === "/api/items" && req.method === "POST") {
      const body = await req.json();
      const name = String(body.name || "").trim();
      if (!name) return json({ error: "name required" }, 400);
      const row = await env.DB
        .prepare("INSERT INTO items (name) VALUES (?) RETURNING *")
        .bind(name)
        .first();
      return json(row, 201);
    }
    const match = path.match(/^\/api\/items\/(\d+)$/);
    if (match && req.method === "DELETE") {
      const id = parseInt(match[1]);
      await env.DB.prepare("DELETE FROM items WHERE id = ?").bind(id).run();
      return json({ ok: true });
    }
    
    // All other paths → static assets (app.html for /, app.js, etc.)
    return env.ASSETS.fetch(req);
  },
};

login.html is public and served without auth:

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, viewport-fit=cover"
    />
    <title>Sign in</title>
  </head>
  <body>
    <form method="post" action="/login" autocomplete="on">
      <input
        type="text"
        name="username"
        value="me"
        autocomplete="username"
        hidden
      />
      <input
        type="password"
        name="password"
        autocomplete="current-password"
        required
        autofocus
      />
      <button type="submit">Sign in</button>
    </form>
  </body>
</html>

app.html is gated and served as the asset for /:

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1, viewport-fit=cover"
    />
    <title>App</title>
    <script type="module" src="/app.js" defer></script>
  </head>
  <body>
    <h1>App</h1>
    <form method="post" action="/logout">
      <button type="submit">Sign out</button>
    </form>
    <div id="root"></div>
  </body>
</html>

app.js handles client-side logic and API calls:

js
async function loadItems() {
  const resp = await fetch("/api/items");
  const items = await resp.json();
  document.getElementById("root").innerHTML = items
    .map(item => `<div>${item.name}</div>`)
    .join("");
}

loadItems();

For each new app, change the schema (step 2 of deploy), the API routes in worker.js, and the contents of app.html and app.js. Keep the auth plumbing, the session module, and the asset-delegation logic.

Modifying

A fresh agent with the Code Mode MCP authorized can land cold on a deployed app, learn everything about it, and modify it. No prior session context is required beyond access to the Cloudflare account. As in the Deploy section, pass each code block as a string to the execute tool's code parameter.

List deployed apps. Pass as a string to execute's code parameter:

js
async () => {
  const workers = await cloudflare.request({
    method: "GET",
    path: `/accounts/${accountId}/workers/scripts`,
  });
  const dbs = await cloudflare.request({
    method: "GET",
    path: `/accounts/${accountId}/d1/database`,
  });
  return { workers: workers.result, databases: dbs.result };
}

Read the deployed Worker source. Pass as a string to execute's code parameter:

js
async () => {
  return cloudflare.request({
    method: "GET",
    path: `/accounts/${accountId}/workers/scripts/my-app`,
  });
}

The response is multipart; the Worker module body is one of the parts. For typed access, the Cloudflare Developer Platform MCP exposes workers_get_worker_code, which returns plain text.

Inspect bindings (find which D1 belongs to this app). Pass as a string to execute's code parameter:

js
async () => {
  return cloudflare.request({
    method: "GET",
    path: `/accounts/${accountId}/workers/scripts/my-app/bindings`,
  });
}

Introspect the D1 schema. Pass as a string to execute's code parameter:

js
async () => {
  const dbId = "uuid";
  return cloudflare.request({
    method: "POST",
    path: `/accounts/${accountId}/d1/database/${dbId}/query`,
    body: { sql: "SELECT name, sql FROM sqlite_master WHERE type='table'" },
  });
}

To re-deploy Worker code only (assets unchanged), use the same multipart PUT as deploy step 5. Because the previous assets.jwt is no longer valid, set assets: { keep_assets: true } in the metadata to retain the existing assets:

js
{
  main_module: "worker.js",
  compatibility_date: "2025-01-01",
  assets: { keep_assets: true, config: { /* ... */ } },
  bindings: [ /* same as before, including same database_id */ ],
}

To re-deploy assets only (Worker code unchanged), repeat deploy steps 3, 4, and 5 with the same Worker code. Omit keep_assets because new assets are being uploaded.

Rotate a secret without redeploying. Pass as a string to execute's code parameter:

js
async () => {
  return cloudflare.request({
    method: "PUT",
    path: `/accounts/${accountId}/workers/scripts/my-app/secrets`,
    body: { name: "APP_PASSWORD", text: "new-password", type: "secret_text" },
  });
}

The new value takes effect on the next request. Rotating SESSION_SECRET logs everyone out; that is the intended recovery move after a suspected leak.

To make schema changes, run raw SQL through the D1 query endpoint. D1 supports most SQLite SQL but not every ALTER TABLE variant. If a migration is rejected, recreate the table with the new shape and copy the data over in a transaction.

Verification checklist

  • Custom connector "Cloudflare Code Mode" appears in Claude settings as connected
  • Account-level <handle>.workers.dev subdomain is claimed (one-time)
  • D1 database appears in GET /accounts/${accountId}/d1/database
  • Worker upload returned success: true with has_modules: true, and handlers: ["fetch"]
  • Hitting https://my-app.<handle>.workers.dev/ while logged out redirects to /login.html
  • GET /login.html returns the login HTML (no auth required)
  • Submitting the correct password redirects to / and sets a cookie
  • The Set-Cookie has HttpOnly, Secure, SameSite=Lax, Path=/, Max-Age=2592000
  • GET /api/items without a session returns 401 {"error":"unauthorized"}
  • GET /api/items with a valid session returns JSON
  • Static assets (/app.js, /login.html) load correctly
  • On first-ever deploy, allow 30–60s for DNS before declaring the URL broken

Common gotchas

  • 10007: You do not have a workers.dev subdomain: Claim the account-level subdomain first (one-time, see Setup).
  • Body can not be decoded as form data: rawBody: true was omitted on a multipart call, or the boundary value in the body doesn't match the one declared in the Content-Type header.
  • Asset routing falls through to the Worker for files that exist: The not_found_handling determines the fallback behavior when an asset path doesn't match a file. "single-page-application" falls back to /index.html; "none" returns 404; "404-page" serves /404.html. Pick one explicitly because the default behavior is surprising.
  • Login page redirects to login page: The auth gate ran on /login.html because the path check happens after isAuthed. The Worker template above special-cases /login.html and /login before the auth gate, but it is easy to forget when adding new public assets.
  • "Safari can't open the page" right after first deploy: DNS hasn't propagated. Wait 60s and retry.
  • 403 on a freshly deployed URL: The account-level subdomain is claimed but this specific Worker isn't opted in. Run deploy step 6 to enable the Worker subdomain.
  • Asset upload returns "all files already uploaded": result.buckets is empty because everything hashes to existing content. Skip step 4 and proceed to step 5 with the jwt from step 3.

See also