Deploying agent2web
agent2web runs on Cloudflare Workers with D1 for metadata and R2 for the files. There is no server to maintain and nothing to keep patched. Run it locally first — that part needs no Cloudflare account at all — then deploy either from a browser or from the terminal.
You will need: a Cloudflare account on the Workers Paid plan ($5/mo), and a
domain on Cloudflare only if you want <slug>.yourdomain.com URLs. The paid
plan is for CPU time, not scale — see why.
Deploy from the browser
One button. Cloudflare clones the repo, creates the database and bucket, and sets up automatic redeploys on every push. Jump to it →
If you want upstream fixesDeploy your own fork
A real fork that Workers Builds deploys, with your ids kept in Cloudflare rather than in git. Updates are one merge. Jump to it →
Run it locally first
Worth doing even if you intend to deploy immediately: it proves the build works on your machine, and it is where you will do all subsequent development. No Cloudflare account is involved — D1 and R2 are emulated on disk.
Install and run the tests
The suite bundles the Worker exactly as deployment does, boots it in Miniflare with local D1 and R2, and drives it over HTTP. A green run means the code path that deploys works.
Create local secrets and start it
Then publish something and open it:
Deploy from the browser
The button reads wrangler.jsonc, provisions what it declares, and
connects Workers Builds so every push to your copy redeploys.
Generate your secrets first
The setup flow asks for them, so have them ready. Run this locally:
A2W_ADMIN_PASSWORD_HASH on the Worker.
Press the button
From the repository README, or directly:
Cloudflare clones the repo into your GitHub account, then provisions the two resources
declared in wrangler.jsonc:
| Binding | Resource | Holds |
|---|---|---|
| DB | D1 database | Sites, versions, file index, OAuth tokens, sessions |
| BLOBS | R2 bucket | The published files themselves |
Paste the secrets from step 01 when asked. Workers Builds is wired up as part of the flow, so later pushes to that repository deploy themselves.
source repo import, with no parents — so it shares no history
with this project. GitHub's Sync fork button will never appear, and
git merge upstream/main refuses outright without
--allow-unrelated-histories, after which every file conflicts. Cloudflare
publishes no guidance on taking upstream changes after a button deploy.
This happens even if you fork this repository first and point the button at your fork: the button always creates a new repository. So if you intend to keep up with the project, take the fork route below instead. If you just want it running and do not care about updates, the button is fine.
The fork route, if you want upstream fixes
Slightly more setup, and then updates are one merge forever. Fork this repository on GitHub, then create the two resources the placeholder cannot know:
wrangler.jsonc. Leave that file exactly as it is upstream; that also means
merging upstream can never conflict.
Connect the fork: Workers & Pages → Create application → Import a repository, pick your fork, and set:
| Setting | Value |
|---|---|
| Build command | npm run build |
| Deploy command | npm run deploy:live |
| Production branch | main, with builds for other branches turned off |
Then, under Settings → Build → Variables, add the build variables
deploy:live reads:
| Build variable | Value |
|---|---|
| A2W_DEPLOY_NAME | The Worker's name, exactly as the dashboard shows it |
| A2W_DEPLOY_D1_ID | The database_id from above |
| A2W_SITES_BASE_DOMAIN, A2W_PUBLIC_URL, … | Any plain-text setting you use, optional |
deploy:live puts these into the deployed config and never into git. It passes
through only known plain-text settings, so a secret entered here by mistake is not deployed as
a readable variable. Add the secrets from step 01 on the Worker itself, under
Settings → Variables and secrets, as type Secret; deploys leave
those alone.
deploy.local.example.json to deploy.local.json (git-ignored),
fill in the same values, and run npm run deploy:live. An environment variable
with the same name wins over the file. Plain npm run deploy uses the
placeholder id and fails.
A2W_DEPLOY_NAME must match the Worker's name in the dashboard. A different name
deploys a second, empty Worker beside yours instead of updating it.
Check it came up
Copy your URL from the end of the build log — the line under
Deployed … triggers. Do not try to guess it: Cloudflare forks the repo as
agent2web-deploy and names the Worker after the fork, so the address is usually
agent2web-deploy.<your-subdomain>.workers.dev rather than
agent2web.….
This request proves the Worker is up, but it deliberately teaches it nothing. The origin
that becomes the
OAuth
issuer is recorded from the first request that proves it came from you — signing in
at /admin, or an authenticated MCP call. An anonymous request is served on
whatever host it arrived at and stores nothing, so a stranger cannot point your issuer at
their domain by being first through the door.
workers.dev URL above, not a per-version preview URL. Whichever origin
you first sign in on is the one that sticks.
A 500 with a configuration message instead means a secret is missing or
malformed — the body names the variable. That is deliberate: bad configuration stops the
Worker rather than half-working.
A2W_PUBLIC_URL to the new origin, as a build variable if you took the fork
route, otherwise under Settings → Variables. From then
on it must match exactly — scheme, host, no trailing slash — because it is the audience of
every token this server issues, and a mismatch breaks Claude's connector while the sites
themselves keep working.
Connect Claude
Claude web or desktop
Settings → Connectors → Add custom connector, with the URL:
Claude registers itself, then sends you to your sign-in page. Enter the admin
password and approve the connection. Claude never sees the password — it receives a token
scoped to publishing, which you can revoke at /admin/connections.
cloudflared tunnel --url http://localhost:8787 and set
A2W_PUBLIC_URL to the tunnel hostname.
Claude Code
Then ask it to publish something. site_publish returns the URL.
Your own domain
Optional. Path URLs work from the moment you deploy; this is about prettier addresses and, for subdomains, giving each site its own browser origin.
| URL form | Needs | Notes |
|---|---|---|
| /s/<slug>/ | nothing | Works immediately, including on workers.dev. Pages get a sandbox
CSP
because they share the Worker's origin, so localStorage and same-origin
fetch will not work in them. |
| <slug>.sites.example.com | a domain on Cloudflare | Each site gets its own origin, so no sandbox and root-absolute paths like
/style.css resolve. |
| reports.customer.example | Cloudflare for SaaS | Per-site custom domains. Supported by the app; the provisioning is manual. |
Subdomain hosting
Three things, all in the Cloudflare dashboard for a domain you have added:
Use a different domain for sites than for the app. Published pages are arbitrary HTML; giving them their own origin is what keeps them away from your admin session.
<slug>.your-worker.workers.dev — wildcard
subdomains do not exist there. Subdomain hosting requires a domain you control.
A custom domain for one site
The Worker answers for that hostname immediately; DNS and the certificate are yours to arrange. The tool prints the steps, and the site's admin page repeats them.
Plan requirements and cost
The $5/mo Workers Paid plan is required for CPU time per request, not for traffic. Password hashing costs 600,000 PBKDF2-SHA256 iterations, about 130 ms. The Free plan caps a request at 10 ms of CPU — enough for roughly 40,000 iterations, an order of magnitude below current guidance. Rather than weaken the hash, agent2web asks for the paid plan.
| Measured | Free plan | Paid plan |
|---|---|---|
| 10,000 iterations · 2.3 ms | fits | fits |
| 50,000 iterations · 10.3 ms | at the ceiling | fits |
| 210,000 iterations · 43 ms | over | fits |
| 600,000 iterations · 130 ms | over | 0.4% of budget |
NotSupportedError: Pbkdf2 failed: iteration counts above 100000 are not supported
— because Cloudflare's CPU limiter cannot interrupt BoringSSL mid-loop and so declines the work
up front. This is a hard cap on both plans, separate from the CPU budget above. So the
600,000 iterations run as six chained rounds of 100,000, each feeding the next: an attacker
still pays the full cost per guess, and no single call breaks the rule.
Worth knowing if you fork this: the cap does not exist locally. workerd removed its own default in commit
12bc98a9, so wrangler dev, Miniflare and the test suite all
run uncapped, and only the deployed enforcer objects. Code that passes every test can still be
broken in production, which is why test/crypto.test.ts asserts the ceiling against
the runtime call itself instead of waiting for a runtime to raise it.
In practice the cost is negligible. The paid plan includes 30M CPU-milliseconds a month, which is about 230,000 password hashes. Real usage is a handful: the owner signs in once per 12-hour session, and a visitor to a protected site pays it once per seven-day cookie. Serving pages is a few milliseconds. R2 gives 10 GB and charges nothing for egress.
curl -u :password) issues no cookie, so it pays the hash on every
request. That path is throttled on the same counter as the login form, which caps both the
brute-force risk and the CPU it can consume.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| Worker returns 500 with a configuration message | Deliberate — bad config stops startup instead of half-working, and the body names the
variable. A scrypt. hash from an older self-hosted build lands here too:
Cloudflare has no scrypt, so regenerate with npm run gen-secrets. |
| Claude's connector will not authenticate, but the site loads fine | Almost always A2W_PUBLIC_URL not matching the URL you dialled. It is the
OAuth issuer and token audience, so it must be exact — scheme, host, no trailing slash or
path. |
406 Not Acceptable from /mcp |
The accept header must list both application/json and
text/event-stream. That is the MCP spec, not a quirk of this server. |
| Admin login rejects the right password | Confirm A2W_ADMIN_PASSWORD_HASH was stored whole. Hashes are dot-separated
precisely so an unquoted $ cannot be eaten by a shell. |
| Subdomain URLs 404 | All three pieces are needed: proxied wildcard DNS, a Workers route for
*.sites.example.com/*, and A2W_SITES_BASE_DOMAIN as a bare
hostname with no port. On workers.dev it cannot work at all. |
A published page cannot use localStorage |
Expected on /s/<slug>/ URLs: they are sandboxed because they share
the Worker's origin, and that cannot be turned off. Give sites their own subdomain
(A2W_SITES_BASE_DOMAIN) or a custom domain, where they are not sandboxed. |
| Want to see what the Worker is doing | npx wrangler tail streams live logs. The dashboard's Observability tab
keeps history. |
What is verified, and what is not
This guide was written alongside the port, and not all of it has been exercised against a live Cloudflare account. Where it has not, that is said rather than implied.
| Claim | Status |
|---|---|
| The 98 tests pass against the real Worker bundle in Miniflare with local D1 and R2 | verified |
npm run dev serves, publishes, gates on password, and runs the admin UI | verified |
wrangler deploy --dry-run resolves both bindings; 259 KB gzipped | verified |
Every wrangler subcommand quoted here exists in wrangler 4.114 | verified |
| PBKDF2 timings in the table above | measured, Apple silicon |
The Deploy button provisions D1 and R2 from wrangler.jsonc and collects the secrets | verified on a real deploy |
A deployed Worker boots, serves /healthz, and publishes correct OAuth metadata on a custom domain | verified on a real deploy |
| Production Workers reject a single PBKDF2 call above 100,000 iterations | found the hard way — it 500s the login form |
| Chained hashing verifies a password on a deployed Worker | verified — admin sign-in succeeds, wrong passwords get 401 |
| 600,000 chained iterations fit the CPU budget on Cloudflare's own hardware | verified — ~130–190 ms over baseline on a live Worker |
| The OAuth server completes DCR, PKCE authorize, consent and token errors when deployed | verified against a live Worker |
| Claude web's connector completes the OAuth flow against a deployed Worker | the server side is verified; Claude's client not yet run |
The unverified rows all need an account, which is the next thing to do. Cloudflare's x86 cores may be 1.5–2× slower than the machine these timings came from, which would put the hash at 200–260 ms — still under 1% of the 30-second budget.