Most SaaS tutorials end with a $50+/month hosting bill before you even have your first customer. Let's fix that.
In this guide, I'll show you how to build a production-ready SaaS backend that costs $0/month to host — with auth, multi-tenancy, Stripe billing, and email. All running on Cloudflare Workers.
The Stack
| Layer | Technology | Cost |
|---|---|---|
| Runtime | Cloudflare Workers | Free (100K req/day) |
| Framework | Hono | Free (npm) |
| Database | Cloudflare D1 (SQLite) | Free (5M rows) |
| Auth | JWT via Web Crypto API | Free |
| Payments | Stripe | 2.9% + $0.30/txn |
| Resend | Free (100 emails/day) | |
| Total | $0/month |
No AWS. No Vercel. No Docker. Just wrangler deploy.
Architecture Overview
┌──────────────────────────────────────────┐
│ Cloudflare Workers │
│ │
│ ┌──────┐ ┌──────┐ ┌────────┐ │
│ │ Auth │ │ Orgs │ │Billing │ Hono │
│ │Routes│ │Routes│ │ Routes │ Router │
│ └──┬───┘ └──┬───┘ └───┬────┘ │
│ │ │ │ │
│ ┌──┴─────────┴───────────┴────┐ │
│ │ D1 (SQLite Database) │ │
│ └──────────────────────────────┘ │
│ │
│ External: Stripe API, Resend API │
└──────────────────────────────────────────┘
Key Design Decisions
1. No Stripe SDK
The Stripe Node.js SDK is massive (~1.5MB) and pulls in Node.js APIs that don't exist in Workers. Instead, we use fetch directly:
async function stripeRequest(
path: string,
apiKey: string,
method: string = "POST",
body?: Record<string, string>
): Promise<any> {
const res = await fetch(`https://api.stripe.com/v1${path}`, {
method,
headers: {
Authorization: `Basic ${btoa(apiKey + ":")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: body
? new URLSearchParams(body).toString()
: undefined,
});
return res.json();
}
This gives us ~50 lines instead of a 1.5MB SDK. And it works perfectly on Workers.
2. JWT with Web Crypto API
No jsonwebtoken package needed. Workers have the Web Crypto API built in:
async function signJwt(
payload: JwtPayload,
secret: string,
expiresIn: number = 86400
): Promise<string> {
const header = { alg: "HS256", typ: "JWT" };
const now = Math.floor(Date.now() / 1000);
const fullPayload = {
...payload,
iat: now,
exp: now + expiresIn,
};
const encodedHeader = base64url(JSON.stringify(header));
const encodedPayload = base64url(JSON.stringify(fullPayload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(signingInput)
);
return `${signingInput}.${base64url(signature)}`;
}
3. PBKDF2 Password Hashing
bcrypt doesn't work on Workers (it needs Node.js crypto). PBKDF2 with 100K iterations via Web Crypto is equally secure:
async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
"PBKDF2",
false,
["deriveBits"]
);
const hash = await crypto.subtle.deriveBits(
{ name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
key,
256
);
return `${toHex(salt)}:${toHex(new Uint8Array(hash))}`;
}
4. Multi-Tenancy with Organizations
Every user belongs to one or more organizations. Data isolation is enforced at the query level:
-- Every data table includes org_id
CREATE TABLE projects (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL REFERENCES organizations(id),
name TEXT NOT NULL,
-- ...
);
-- Queries always filter by org_id
SELECT * FROM projects WHERE org_id = ?;
Roles (owner > admin > member) control what each user can do within an organization.
Database Schema
Five core tables handle everything:
CREATE TABLE users (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
email TEXT UNIQUE NOT NULL,
password_hash TEXT,
name TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE organizations (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
stripe_customer_id TEXT,
plan TEXT NOT NULL DEFAULT 'free',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE memberships (
user_id TEXT NOT NULL REFERENCES users(id),
org_id TEXT NOT NULL REFERENCES organizations(id),
role TEXT NOT NULL DEFAULT 'member',
PRIMARY KEY (user_id, org_id)
);
CREATE TABLE subscriptions (
id TEXT PRIMARY KEY,
org_id TEXT UNIQUE NOT NULL REFERENCES organizations(id),
stripe_subscription_id TEXT UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
plan TEXT NOT NULL DEFAULT 'pro',
current_period_end TEXT
);
CREATE TABLE magic_links (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
email TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at TEXT NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
Getting Started
# Clone the repo
git clone https://github.com/Docat0209/cf-workers-saas-kit.git
cd cf-workers-saas-kit
# Install dependencies (just hono)
npm install
# Set up D1 database
wrangler d1 create saas-kit-db
# Update wrangler.toml with your database_id
# Run migration
wrangler d1 execute saas-kit-db --file=migrations/0001_initial.sql
# Configure environment
cp .dev.vars.example .dev.vars
# Fill in your Stripe keys, JWT secret, etc.
# Start development
wrangler dev
# Deploy to production
wrangler deploy
That's it. Your SaaS backend is live at your-worker.your-subdomain.workers.dev.
API Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /auth/register | Create account |
| POST | /auth/login | Get JWT token |
| POST | /auth/magic-link | Send magic link email |
| GET | /auth/verify | Verify magic link |
| GET | /users/me | Get current user |
| PATCH | /users/me | Update profile |
| POST | /orgs | Create organization |
| GET | /orgs | List user's orgs |
| GET | /orgs/:id | Get org details |
| PATCH | /orgs/:id | Update org |
| POST | /orgs/:id/members | Invite member |
| DELETE | /orgs/:id/members/:userId | Remove member |
| POST | /billing/checkout | Create Stripe checkout |
| POST | /billing/portal | Stripe customer portal |
| GET | /billing/status | Subscription status |
| POST | /billing/webhook | Stripe webhook handler |
| GET | /health | Health check |
What This Costs in Production
With Cloudflare's free tier:
- 100,000 requests/day — enough for most early-stage SaaS
- 5 million D1 rows — plenty for thousands of users
- 100 emails/day via Resend free tier
When you outgrow the free tier, Cloudflare Workers Paid starts at $5/month for 10M requests. Compare that to a basic AWS/Vercel setup at $20-50/month.
The "Why Build on Workers" Case
- Cold start: <1ms — Workers run on V8 isolates, not containers
- Global by default — Your code runs in 300+ data centers worldwide
- D1 is SQLite — Write real SQL, not DynamoDB expressions
- Built-in KV, R2, Queues — Add caching, storage, async processing later
- Free tier is generous — You won't pay a cent until you have real traction
Get the Complete Kit
The full source code is on GitHub: cf-workers-saas-kit
Want premium support, priority updates, and architectural guidance? Get the Pro version on Gumroad ($19) — includes a 1-hour architecture review via email.
Also check out FreeTools — 21 free browser-based developer tools, and our CLAUDE.md Mega Collection ($12) for AI-assisted development.
Top comments (0)