Open-source form-building platform that runs entirely on Cloudflare Workers + Pages + D1 + R2
Inspired by Formbricks. Multi-tenant, white-label, embeddable, kiosk-ready.
- 🚀 Fully serverless deployment
- 🆓 Works within Cloudflare free tier
- 🔒 Your data stays in your Cloudflare account
- 🧩 Embeddable forms for any website
- 🏷 Multi-tenant + white-label support
- 🖥 Kiosk-ready deployments
If there is interest in a hosted / managed ("enterprise") version of CloudyForms, I'm open to exploring that.
Feel free to reach out via the links above if you'd like to discuss it.
- About
- Features
- Architecture
- Prerequisites
- Quick Start
- Cloudflare Setup (step-by-step)
- Deploying via Cloudflare Dashboard (recommended)
- Alternative: GitHub Actions Deployment
- Environment Variables
- Custom Domains
- Embedding Forms
- Self-Hosting on Your Own Account
- Free vs Paid Tier
- Development
CloudyForms was built as a self-hosted alternative to Google Forms, Formbricks and similar platforms, designed specifically for the Cloudflare serverless ecosystem.
It allows you to deploy a full form platform using only:
- Cloudflare Workers
- Cloudflare Pages
- Cloudflare D1
- Cloudflare R2
— while remaining compatible with the Cloudflare free tier.
The project is intended to be easy to fork, easy to deploy, and simple to embed into existing sites.
Suggestions, issues and pull requests are very welcome.
If CloudyForms saves you time or infrastructure costs, consider supporting development:
I'm an independent developer working on open-source tools around privacy, self-hosting and serverless infrastructure.
I'm currently between projects and focusing on building useful open-source tools, so if this project helps you, sharing it or supporting development is hugely appreciated.
You can find my other projects and links here:
🔗 https://links.thecuckoocamp.co.uk/@cuckooadam
| Feature | Notes |
|---|---|
| Drag-and-drop form builder | @dnd-kit, 17 field types |
| Multi-tenant organisations | Owner / Admin / Editor / Viewer roles |
| Granular ACLs | Per-form, per-role field visibility |
| Custom domains | Multiple verified domains per org |
| Form embedding | iframe + JS widget, auto-resizing |
| Kiosk mode | Token-based device registration, multi-form |
| Cloudflare Turnstile | Anti-spam, no CAPTCHA |
| File uploads | R2 storage + D1 blob for small files |
| Email notifications | Mailchannels (free on Cloudflare Workers) |
| Webhooks | HMAC-signed payloads |
| CSV / JSON export | Per-form or per-response |
| Device fingerprinting | Duplicate submission detection |
| Reusable field groups | Global or org-scoped templates |
| Custom branding | Logo, colours, font per org/form |
| Mobile responsive | Tailwind CSS throughout |
┌─────────────────────────────────────────┐
│ Cloudflare Pages (frontend) │
│ React + Vite → /frontend │
└───────────────┬─────────────────────────┘
│ /api/* (proxied)
┌───────────────▼─────────────────────────┐
│ Cloudflare Worker (backend) │
│ Hono router → /worker │
│ • D1 database (SQLite) │
│ • R2 bucket (file storage) │
│ • Turnstile (anti-spam) │
│ • Mailchannels (email) │
└─────────────────────────────────────────┘
- Node.js >= 18
- Wrangler CLI >= 3
npm install -g wrangler - A Cloudflare account (free tier works for most features)
⚠️ Security note: Never commit secrets (API keys, JWT secrets, Turnstile keys) intowrangler.toml,.envfiles, or any other file that is pushed to a public repository. Usewrangler secret putfor worker secrets and the Cloudflare Dashboard environment variables UI for Pages build-time variables.
# 1. Clone the repository
git clone https://github.com/halcycon/cloudyforms.git
cd cloudyforms
# 2. Install dependencies
cd worker && npm install && cd ../frontend && npm install && cd ..
# 3. Authenticate with Cloudflare
npx wrangler login
# 4. Create D1 database
npx wrangler d1 create cloudyforms
# Copy the database_id into worker/wrangler.toml
# 5. Apply the schema
cd worker && npx wrangler d1 execute cloudyforms --remote --file=worker/src/db/schema.sql
# 6. Create R2 bucket
npx wrangler r2 bucket create cloudyforms-files
# 7. Set secrets (these are stored securely by Cloudflare, NOT in your repo)
npx wrangler secret put JWT_SECRET # random 32+ char string
npx wrangler secret put TURNSTILE_SECRET_KEY # from Cloudflare dashboard
npx wrangler secret put MAILCHANNELS_API_KEY # optional
# 8. Deploy the worker
cd worker && npx wrangler deploy
# 9. Deploy the frontend via Cloudflare Dashboard (recommended)
# See "Deploying via Cloudflare Dashboard" below.
# Or deploy manually with Wrangler:
cd ../frontend
VITE_API_URL="https://cloudyforms-worker.<your-account>.workers.dev/api" npm run build
wrangler pages deploy dist --project-name cloudyformsnpx wrangler d1 create cloudyformsCopy the database_id output into worker/wrangler.toml:
[[d1_databases]]
binding = "DB"
database_name = "cloudyforms"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"Apply schema (must be done within the worker directory to reference the wrangler.toml):
cd worker && npx wrangler d1 execute cloudyforms --remote --file=worker/src/db/schema.sqlnpx wrangler r2 bucket create cloudyforms-files- Cloudflare Dashboard → Turnstile → Add widget
- Widget type: Managed
- Add your frontend domain as an allowed hostname
- Copy Site Key → you will add this as an environment variable in the Cloudflare Pages dashboard (see Deploying via Cloudflare Dashboard) or pass it at build time as
VITE_TURNSTILE_SITE_KEY - Copy Secret Key →
wrangler secret put TURNSTILE_SECRET_KEY
Mailchannels is free for Workers. No API key needed for basic use if your domain has a valid SPF record. For authenticated sending: wrangler secret put MAILCHANNELS_API_KEY
Also set in wrangler.toml:
[vars]
FROM_EMAIL = "noreply@yourdomain.com"node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
npx wrangler secret put JWT_SECRETcd worker && npx wrangler deployNote your workers.dev URL: https://cloudyforms-worker.<account>.workers.dev
The recommended approach is to deploy the frontend via the Cloudflare Dashboard so that Cloudflare builds and deploys automatically whenever you push to your repository. See Deploying via Cloudflare Dashboard for full instructions.
Alternatively, deploy manually with Wrangler:
cd frontend
VITE_API_URL="https://cloudyforms-worker.<account>.workers.dev/api" \
VITE_TURNSTILE_SITE_KEY="your-turnstile-site-key" \
npm run build
wrangler pages deploy dist --project-name cloudyformsTip: Pass environment variables inline or export them in your shell session rather than writing them to a
.envfile that could accidentally be committed to a public repository.
Linking your repository directly in the Cloudflare Dashboard is the simplest way to deploy the frontend. Cloudflare will automatically build and publish your site whenever you push changes — no GitHub Actions or API tokens required.
The Worker (backend API) must be deployed first because the frontend needs its URL.
cd worker
npx wrangler deployNote your Worker URL — it will look like https://cloudyforms-worker.<your-account>.workers.dev.
Note: There is no built-in Cloudflare Dashboard integration for Workers tied to a Git repo. You can deploy the Worker manually with
wrangler deploy, or use the GitHub Actions workflow described below if you want automated Worker deployments on push.
- Log in to the Cloudflare Dashboard
- Navigate to Workers & Pages in the left sidebar
- Click Create → Pages → Connect to Git
- Authorise Cloudflare to access your GitHub account (if not already done)
- Select your cloudyforms repository (or your fork of it)
- Click Begin setup
| Setting | Value |
|---|---|
| Production branch | main |
| Framework preset | None |
| Root directory | frontend |
| Build command | npm run build |
| Build output directory | dist |
Why set Root directory to
frontend? This tells Cloudflare Pages to run the build command from inside thefrontend/folder, so paths resolve correctly and only frontend changes trigger rebuilds.
Still on the same setup page, expand Environment variables and add:
| Variable name | Value | Notes |
|---|---|---|
VITE_API_URL |
https://cloudyforms-worker.<your-account>.workers.dev/api |
Replace with your actual Worker URL from Step 1 |
VITE_TURNSTILE_SITE_KEY |
(your Turnstile site key) | From Cloudflare Dashboard → Turnstile |
NODE_VERSION |
20 |
Ensures Cloudflare uses Node 20 for the build |
Security: These variables are stored securely in Cloudflare's build environment. They are not committed to your repository and are not publicly visible.
Click Save and Deploy. Cloudflare will clone your repo, install dependencies, run the build, and publish the site. You'll see a deployment URL like https://cloudyforms.pages.dev.
Open your Pages URL in a browser. You should see the CloudyForms login/signup page. Confirm that the frontend can reach the Worker API by signing up or logging in.
From now on, every push to your main branch that changes files under frontend/ will trigger a new production deployment automatically. Pull requests will generate preview deployments with unique URLs so you can test changes before merging.
You can review deployment history and preview URLs under Workers & Pages → cloudyforms → Deployments in the Cloudflare Dashboard.
- In the Cloudflare Dashboard, go to Workers & Pages → cloudyforms → Custom domains
- Click Set up a custom domain
- Enter your domain (e.g.
forms.yourdomain.com) and follow the DNS prompts
CloudyForms also ships with two GitHub Actions workflows in .github/workflows/ that automate deployment whenever you push to main (or open a pull request). This approach is useful if you want CI-driven deployments for both the Worker and the frontend, or if you need to run additional checks before deploying.
Note: If you are already using the Cloudflare Dashboard to deploy the frontend (see above), you do not need the
deploy-pages.ymlworkflow. You may still usedeploy-worker.ymlto automate Worker deployments.
| Workflow | File | Trigger |
|---|---|---|
| Deploy Worker | deploy-worker.yml |
Push/PR touching worker/** |
| Deploy Pages | deploy-pages.yml |
Push/PR touching frontend/** |
Add these in GitHub → Repository → Settings → Secrets and variables → Actions:
| Secret | Description |
|---|---|
CLOUDFLARE_API_TOKEN |
API token with Cloudflare Workers Scripts:Edit and Cloudflare Pages:Edit permissions |
CLOUDFLARE_ACCOUNT_ID |
Your Cloudflare Account ID (found in the dashboard right-hand panel) |
VITE_API_URL |
Full URL of your deployed worker, e.g. https://cloudyforms-worker.<account>.workers.dev/api |
VITE_TURNSTILE_SITE_KEY |
Cloudflare Turnstile site key (optional but recommended) |
- Cloudflare Dashboard → My Profile → API Tokens → Create Token
- Use the Edit Cloudflare Workers template and add Cloudflare Pages:Edit permission
- Scope the token to your account
- Copy the token value into the
CLOUDFLARE_API_TOKENGitHub Secret
deploy-worker.yml
- Runs
npm ciin theworker/directory - On push to
main: runswrangler deployto publish the Worker to production - Pull requests only trigger the install step (no deployment) so your CI still catches build errors
deploy-pages.yml
- Runs
npm ci+npm run buildin thefrontend/directory (usingVITE_API_URLandVITE_TURNSTILE_SITE_KEYfrom secrets) - On push to
main: deploysfrontend/distto thecloudyformsPages project on themainbranch - On pull request: deploys a preview to a
pr-<number>branch and posts the preview URL as a PR comment
The Worker must still be deployed via wrangler deploy (or the deploy-worker.yml GitHub Action) because Cloudflare Pages' built-in Git integration only covers the Pages frontend.
⚠️ Important: Secrets must never be added towrangler.tomlor committed to your repository. Usewrangler secret put <NAME>for worker secrets and the Cloudflare Dashboard for Pages build variables.
Non-sensitive config (safe to keep in wrangler.toml under [vars]):
| Variable | Required | Description |
|---|---|---|
FROM_EMAIL |
Yes | Sender address for emails |
ALLOWED_ORIGINS |
Optional | Comma-separated CORS origins (default *) |
ENVIRONMENT |
Optional | development enables verbose errors |
Secrets (set via wrangler secret put <NAME>):
| Secret | Required | Description |
|---|---|---|
JWT_SECRET |
Yes | JWT signing secret (32+ chars) |
TURNSTILE_SECRET_KEY |
Yes | Cloudflare Turnstile secret key |
MAILCHANNELS_API_KEY |
Optional | Mailchannels authenticated sending |
Set these as environment variables in the Cloudflare Pages dashboard (or pass them inline when building locally). Do not commit them to the repository.
| Variable | Description |
|---|---|
VITE_API_URL |
Worker API base URL |
VITE_TURNSTILE_SITE_KEY |
Cloudflare Turnstile site key |
See docs/cloudflare-integration.md for full Cloudflare Tunnels, CNAME, and Page Rules setup.
Quick summary:
- Org admin: Org Settings → Custom Domains → Add Domain
- Add a DNS TXT record to verify ownership
- Point a CNAME to the workers.dev URL
- Configure a Cloudflare Custom Hostname or Tunnel
- Mark domain as primary – all share links use it
Global admins can view and force-verify all domains at Admin → Manage Custom Domains.
See docs/embedding.md for full details.
Quick iframe embed:
<iframe
src="https://your-instance.pages.dev/embed/your-form-slug"
style="width:100%;border:none;min-height:480px;"
frameborder="0" scrolling="no"
></iframe>
<script>
window.addEventListener('message', function(e) {
if (e.data && e.data.type === 'cloudyforms:resize') {
document.querySelectorAll('iframe[src*="your-form-slug"]')
.forEach(function(f) { f.style.height = (e.data.height + 32) + 'px'; });
}
});
</script>JavaScript widget (auto-resizing):
<script src="https://your-instance.pages.dev/api/embed/script.js" defer></script>
<div data-cloudyforms="your-form-slug"></div>The embed code is also available inside the Form Builder → Embed tab.
CloudyForms is designed to be self-hosted. Each deployment is completely independent.
git clone https://github.com/your-fork/cloudyforms.git
cd cloudyforms
# Follow the Quick Start above.
# All data stays in YOUR Cloudflare account.Key points:
- No external SaaS – only Cloudflare primitives + optional Mailchannels
- One account can host multiple isolated deployments (different wrangler.toml / D1 databases)
- First registered user automatically becomes super admin
| Feature | Free | Paid |
|---|---|---|
| Worker requests | 100k/day | Unlimited |
| D1 storage | 5 GB | 50 GB+ |
| R2 storage | 10 GB | Pay as you go |
| Custom domains via CNAME + Tunnel | Free | Free |
| Custom Hostnames (SSL for SaaS) | No | Enterprise |
| Email (Mailchannels) | Free | Free |
| Turnstile | Free | Free |
Recommendation: Free tier is sufficient for small-medium deployments. Workers Paid ($5/mo) for > 100k form views/day.
# Worker (localhost:8787)
cd worker && wrangler dev
# Frontend (localhost:5173, proxies /api to :8787)
cd frontend && npm run devLocal D1 – apply schema once:
cd worker
wrangler d1 execute cloudyforms --local --file=src/db/schema.sqlschema.sql uses CREATE TABLE IF NOT EXISTS, so re-running it is safe for
new tables — they'll be created without touching existing ones. However,
new columns added to existing tables (e.g. current_stage on
form_responses) are inside the original CREATE TABLE block and will not
be applied to a database that already has that table.
For this reason, numbered migration files live in
worker/src/db/migrations/. Run them in order after pulling a new version:
cd worker
# Remote (production)
npx wrangler d1 execute cloudyforms --remote --file=src/db/migrations/001_add_workflow_support.sql
# Local (development)
npx wrangler d1 execute cloudyforms --local --file=src/db/migrations/001_add_workflow_support.sqlNote: On a fresh install where
schema.sqlwas already applied, theALTER TABLEin the migration will produce a harmless "duplicate column name" error. All other statements in the file useIF NOT EXISTSand will succeed regardless.
After running all migrations, you can optionally re-run schema.sql to ensure
indexes and any new tables are present:
npx wrangler d1 execute cloudyforms --remote --file=src/db/schema.sqlMIT – see LICENSE.