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.

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Deploy to Cloudflare Button skill

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:

[![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

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