| name | cloudflare-deploy-button |
| description | "Deploy to Cloudflare" button for self-hostable Workers apps. Covers wrangler.jsonc setup, D1 migrations in deploy script, secret descriptions, .dev.vars.example, monorepo handling, custom domains, and the cloudflare field in package.json. Load this skill when adding a deploy button to a Cloudflare Workers project or making an app easy to self-host on Cloudflare.
|
Deploy to Cloudflare Button
Make any Cloudflare Workers app deployable with one click. Users click a
button in your README → Cloudflare forks the repo, provisions all
resources (D1, KV, R2, etc.), and deploys the worker. No CLI needed.
The button
Add this to your README:
[](https://deploy.workers.cloudflare.com/?url=https://github.com/OWNER/REPO)
For a subdirectory in a monorepo:
[](https://deploy.workers.cloudflare.com/?url=https://github.com/OWNER/REPO/tree/main/packages/my-app)
What the button does automatically
- Forks the repo into the user's GitHub/GitLab
- Reads
wrangler.jsonc to discover required resources
- Auto-provisions D1 databases, KV namespaces, R2 buckets, Durable Objects, Queues, Vectorize, Workers AI, Hyperdrive, Secrets Store
- Replaces placeholder
database_id, namespace_id, etc. with real IDs
- Runs
build then deploy scripts from package.json
- Sets up Workers Builds CI/CD — future pushes auto-deploy
What the button does NOT do
- Custom domains — the worker gets
*.workers.dev. Users add custom domains from the Cloudflare dashboard after deploy.
- Run arbitrary setup scripts — only
build and deploy from package.json
- Clone git submodules — submodules are NOT cloned. All dependencies must be resolvable via the package manager.
Required setup in your repo
1. package.json scripts — migrations in deploy
The deploy button runs the deploy script. Chain D1 migrations before
wrangler deploy using && so they run as a single atomic step.
Always reference the binding name (DB), not the database name, because users can customize the database name during setup.
{
"scripts": {
"build": "vite build",
"deploy": "wrangler d1 migrations apply DB --remote && wrangler deploy"
}
}
If no deploy script exists, the button defaults to npx wrangler deploy.
If no build script exists, the build step is skipped.
2. Secret descriptions in package.json
Add a cloudflare.bindings section to describe secrets. These descriptions
are shown to users on the deploy setup page so they know what to enter.
Supports inline markdown: `code`, **bold**, __italics__, and [links](url).
{
"cloudflare": {
"bindings": {
"MY_SECRET": {
"description": "Random signing key. Generate with `openssl rand -base64 32`."
},
"API_KEY": {
"description": "Your [Stripe API key](https://dashboard.stripe.com/apikeys) (starts with `sk_live_`)."
}
}
}
}
3. .dev.vars.example for secrets
Create a .dev.vars.example (or .env.example) file with dotenv format.
The deploy button reads this and shows input fields for each secret:
BETTER_AUTH_SECRET=change-me # Random string for signing sessions
ENCRYPTION_KEY= # Optional: openssl rand -base64 32
4. Template metadata (optional)
For apps listed in Cloudflare's template gallery, add metadata:
{
"cloudflare": {
"label": "My App",
"products": ["Workers", "D1"],
"categories": ["storage"],
"publish": true
}
}
5. wrangler.jsonc — keep placeholder IDs
The button replaces database_id, namespace_id, etc. with real values
after provisioning. Keep your existing IDs in the config — they serve as
placeholders and are overwritten for new deployers.
{
"name": "my-app",
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-db",
"database_id": "placeholder-gets-replaced",
"migrations_dir": "./migrations"
}
]
}
routes with custom_domain: true are safe to keep. The button
ignores domains it can't configure. The worker deploys to *.workers.dev
and users add custom domains later from the dashboard.
secrets.required tells wrangler which secrets must exist. Keep this —
it validates that users set their secrets after deploy.
Custom domains
The deploy button does NOT set up custom domains. After deploy:
- Worker is accessible at
my-app.username.workers.dev
- User goes to Cloudflare dashboard → Workers → their worker → Settings → Domains & Routes
- User adds their own domain (must be in their Cloudflare account)
Document this as a post-deploy step in your README. Example:
### Custom domain (optional)
After deploying, your app runs at `https://your-worker.workers.dev`.
To use your own domain:
1. Add your domain to Cloudflare (it must use Cloudflare DNS)
2. Go to **Workers & Pages** → your worker → **Settings** → **Domains & Routes**
3. Click **Add** → **Custom Domain** and enter your domain
Monorepo handling
The problem
The deploy button supports a ?url= subdirectory path, but from the
official docs:
If your repository URL contains a subdirectory, your application must be
fully isolated within that subdirectory, including any dependencies.
Cloudflare treats this subdirectory as the root of the new repository.
This means pointing at a subdirectory like /tree/main/app breaks
if the app has workspace:^ dependencies on sibling packages. The
subdirectory is extracted alone — workspace siblings are not included.
Git submodules are also not cloned by the deploy button.
Always use the repo root
Never point the deploy button at a subdirectory if you have workspace
dependencies. Always point at the repo root and add build and
deploy scripts to the root package.json that delegate to the
app package:
{
"scripts": {
"build": "pnpm --filter my-app build",
"deploy": "wrangler d1 migrations apply DB --remote && wrangler deploy"
}
}
The deploy button URL should be the repo root, not a subdirectory:
<!-- ✅ Correct — repo root -->
[](https://deploy.workers.cloudflare.com/?url=https://github.com/owner/repo)
<!-- ❌ Wrong — subdirectory with workspace deps -->
[](https://deploy.workers.cloudflare.com/?url=https://github.com/owner/repo/tree/main/app)
The entire repo is cloned including all workspace packages, so
workspace:^ dependencies resolve correctly. The wrangler.jsonc
inside the app subfolder is discovered by wrangler automatically (or
you can pass --config app/wrangler.jsonc in the deploy script).
Submodules: if a workspace dep is a git submodule, remove it and
depend on the published npm package instead. Submodules are never
cloned by the deploy button.
What happens if the repo owner clicks their own button
- Same Cloudflare account: the worker name from
wrangler.jsonc is
used as default. If a worker with that name already exists, it will be
overwritten. Change the name on the setup page to avoid this.
- Different Cloudflare account: works fine — creates a fresh worker
with a new D1 database.
The setup page lets users customize: worker name, database names,
resource names, and secret values before deploying.
Full example: self-hostable app with D1
wrangler.jsonc
{
"name": "my-app",
"compatibility_date": "2026-05-01",
"compatibility_flags": ["nodejs_compat"],
"main": "src/index.ts",
"d1_databases": [
{
"binding": "DB",
"database_name": "my-app-db",
"database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"migrations_dir": "./migrations"
}
],
"vars": {
"PUBLIC_URL": "https://my-app.example.com"
},
"secrets": {
"required": ["AUTH_SECRET"]
},
"routes": [
{ "pattern": "my-app.example.com", "custom_domain": true }
]
}
package.json
{
"name": "my-app",
"scripts": {
"build": "vite build",
"deploy": "wrangler d1 migrations apply DB --remote && wrangler deploy",
"dev": "wrangler d1 migrations apply DB --local && wrangler dev"
},
"cloudflare": {
"label": "My App",
"products": ["Workers", "D1"],
"bindings": {
"AUTH_SECRET": {
"description": "Random string for signing sessions. Generate with `openssl rand -base64 32`."
}
}
}
}
.dev.vars.example
AUTH_SECRET=change-me # openssl rand -base64 32
README.md
## Self-hosting
[](https://deploy.workers.cloudflare.com/?url=https://github.com/owner/my-app)
After clicking the button:
1. Set `AUTH_SECRET` to a random string (the deploy page will prompt you)
2. Your app is live at `https://my-app.USERNAME.workers.dev`
3. (Optional) Add a custom domain from Workers settings in the Cloudflare dashboard
Derive URLs from the request — never hardcode app URLs
Self-hosted apps must work on any domain the deployer chooses. Never
hardcode your own domain in env vars like BETTER_AUTH_URL, APP_URL,
or PUBLIC_ORIGIN. Instead, derive the URL from the incoming request:
const origin = new URL(request.url).origin
const auth = betterAuth({
baseURL: origin,
})
const auth = betterAuth({
baseURL: env.APP_URL,
})
This matters for BetterAuth specifically because baseURL is used to:
- Generate OAuth callback URLs —
{baseURL}/api/auth/callback/{provider}
- Set cookie scope — cookie domain is derived from baseURL
- Validate redirects — checks origins against baseURL + trustedOrigins
If baseURL doesn't match the actual domain, OAuth flows break with
redirect_uri_mismatch and cookies don't get set correctly.
Why this is safe on Cloudflare Workers
Using the request host as baseURL is normally dangerous — an attacker
could forge the Host header and trick the app into generating callbacks
to a malicious domain. But Cloudflare Workers are safe because:
- Cloudflare controls routing — a Worker only receives requests for
domains explicitly configured in the dashboard (Custom Domains or
*.workers.dev). You cannot invoke a worker with a forged host.
- Host header is locked — Cloudflare overwrites the Host header to
match the actual routed domain. Forged Host headers are blocked.
- No upstream proxy — unlike Node.js behind nginx where Host can
be spoofed, Cloudflare IS the edge. There's no intermediary.
References:
Pattern for localhost/tunnel support
For local development behind a tunnel (e.g. kimaki tunnel, cloudflared),
the request URL shows localhost but the real public URL is different.
Handle this by checking forwarded headers only on localhost:
function getRequestOrigin(request: Request): string {
const url = new URL(request.url)
if (url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') {
return url.origin
}
const forwardedHost = request.headers.get('x-forwarded-host')
if (forwardedHost) {
const host = forwardedHost.split(',')[0]!.trim()
const proto = request.headers.get('x-forwarded-proto')?.split(',')[0]?.trim() ?? 'https'
return `${proto}://${host}`
}
return url.origin
}
What about vars like PUBLIC_URL in wrangler.jsonc?
If your app has a PUBLIC_URL or APP_URL var in wrangler.jsonc for
things like email links or OpenGraph tags, remove it and derive from
the request instead. Any hardcoded URL becomes a config burden for
self-hosters. The only exception is when the URL must be known at build
time (e.g. baked into static HTML) — in that case document it clearly as
a required post-deploy configuration step.
References