DEV Community

Docat
Docat

Posted on

How to Build a Multi-Tenant SaaS on Cloudflare Workers for \/usr/bin/bash/Month

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
Email 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        │
└──────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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)}`;
}
Enter fullscreen mode Exit fullscreen mode

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))}`;
}
Enter fullscreen mode Exit fullscreen mode

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 = ?;
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

  1. Cold start: <1ms — Workers run on V8 isolates, not containers
  2. Global by default — Your code runs in 300+ data centers worldwide
  3. D1 is SQLite — Write real SQL, not DynamoDB expressions
  4. Built-in KV, R2, Queues — Add caching, storage, async processing later
  5. 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)