Deploy to Cloudflare Button
SkillCloud & infra"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.
Use Deploy to Cloudflare Button in Claude, ChatGPT or Ahel Desktop
Free. Sign in, add Deploy to Cloudflare Button and connect your AI. About a minute.
Also: Claude Code · Cursor · Codex
Then ask your AI: use the Deploy to Cloudflare Button skill
Details
Instructions available. Your AI can read the instructions. Execution depends on the setup they require.
Account requirements not reviewed. Check the skill instructions before use; Ahel provides instructions and does not run this skill.
No other account needed.
Add Ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
What this skill tells your AI
The instructions your AI receives, as published by remorses/opencode-config in skills/cloudflare-deploy-button/SKILL.md and read by Ahel’s review.
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.jsoncto 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
buildthendeployscripts frompackage.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
buildanddeployfrompackage.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.jsoncis 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:
// ✅ Correct — works on any domain
const origin = new URL(request.url).origin
const auth = betterAuth({
baseURL: origin,
// ...
})
// ❌ Wrong — breaks for every self-hoster
const auth = betterAuth({
baseURL: env.APP_URL, // hardcoded to your domain
})
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:
- Custom Domains require exact hostname match: https://developers.cloudflare.com/workers/configuration/routing/custom-domains/
- Cloudflare blocks forged Host headers: https://news.ycombinator.com/item?id=25058579
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)
// On non-localhost, trust the request URL directly (Cloudflare guarantees it)
if (url.hostname !== 'localhost' && url.hostname !== '127.0.0.1') {
return url.origin
}
// On localhost, check forwarded headers for tunnel support
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
- Official docs: https://developers.cloudflare.com/workers/platform/deploy-buttons/
- Official templates repo (canonical reference for patterns): https://github.com/cloudflare/templates
d1-template/package.json— D1 + migrations patternsaas-admin-template/package.json— Astro + D1 + workflowsopenauth-template/package.json— OpenAuth + D1 + KV
- Legacy deploy button source (GitHub Actions-based, older): https://github.com/cloudflare/deploy.workers.cloudflare.com
- Workers SDK (Wrangler source): https://github.com/cloudflare/workers-sdk
- D1 migrations docs: https://developers.cloudflare.com/d1/reference/migrations/
- Custom domains docs: https://developers.cloudflare.com/workers/configuration/custom-domains/
- Workers Builds (CI/CD): https://developers.cloudflare.com/workers/ci-cd/builds/
Signals
- GitHub stars
- 43
- Forks
- 2
- Last commit
- Sep 2026
Ahel review
S4info
community integration, published by remorses, not cloudflare
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Item type
- skill
- Key
cloudflare-deploy-button- Source
- github.com/remorses/opencode-config
github.com/remorses/opencode-config
Related picks
Skill · cloudflare
The pick for Cloudflarenodejs-backend-patterns
Skill · wshobson
The pick for Noderun-node-tests
Skill · hiroro-work
The pick for Nodevercel-react-best-practices
Skill · vercel-labs
More in Cloud & infraweb-design-guidelines
Skill · vercel-labs
More in Cloud & infraturborepo
Skill · vercel
More in Cloud & infra