cloudflare-deploy-button

"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, an

SKILL.md

GitHub

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:

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/OWNER/REPO)

For a subdirectory in a monorepo:

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/OWNER/REPO/tree/main/packages/my-app)

What the button does automatically

  1. Forks the repo into the user's GitHub/GitLab
  2. Reads wrangler.jsonc to discover required resources
  3. Auto-provisions D1 databases, KV namespaces, R2 buckets, Durable Objects, Queues, Vectorize, Workers AI, Hyperdrive, Secrets Store
  4. Replaces placeholder database_id, namespace_id, etc. with real IDs
  5. Runs build then deploy scripts from package.json
  6. 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:

  1. Worker is accessible at my-app.username.workers.dev
  2. User goes to Cloudflare dashboard → Workers → their worker → Settings → Domains & Routes
  3. 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 -->
[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/owner/repo)

<!-- ❌ Wrong — subdirectory with workspace deps -->
[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](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

[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](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:

  1. 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.
  2. Host header is locked — Cloudflare overwrites the Host header to match the actual routed domain. Forged Host headers are blocked.
  3. 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)

  // 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