Supabase + Cloudflare Pages:零成本全端部署
使用 Supabase(免費 PostgreSQL)和 Cloudflare Pages(免費託管)建構生產等級的全端應用程式。完整指南涵蓋資料庫設定、Edge Functions、安全模式及部署。
社群模式說明: 本指南記錄了建構 claude-world.com 所使用的實際實作模式。這些模式經過生產環境測試,可以根據您的專案需求進行調整。
建構全端應用程式通常需要支付託管、資料庫和基礎設施的費用。但透過正確的服務組合,您可以將生產等級的應用程式部署成本降至每月 $0。
本指南涵蓋完整的 Supabase + Cloudflare Pages 整合模式,包含資料庫設定、Edge Functions、安全強化及部署。
為什麼選擇這個技術棧?
| 元件 | 用途 | 費用 |
|---|---|---|
| Cloudflare Pages | 靜態託管 + Edge Functions | 免費 |
| Supabase | PostgreSQL 資料庫 + Auth | 免費方案 |
| Astro | 靜態網站生成器,支援 SSR | 免費 |
MVP 總成本:$0/月(網域約 $10/年)
此組合提供:
- 全球 CDN - Cloudflare 的邊緣網路提供快速內容傳遞
- Edge Functions - 無需管理伺服器的伺服器端邏輯
- PostgreSQL - Supabase 提供完整的關聯式資料庫
- Row Level Security - 資料庫層級的存取控制
- 自動擴展 - 無需設定即可處理流量尖峰
專案結構
了解目錄結構有助於邏輯性地組織程式碼:
project/
├── src/
│ ├── lib/
│ │ ├── supabase.ts # 資料庫客戶端
│ │ ├── rate-limit.ts # 速率限制
│ │ └── security.ts # CSRF 保護
│ └── pages/
│ └── api/ # Edge functions
├── supabase/
│ └── migrations/ # SQL migrations
├── wrangler.toml # Cloudflare 設定
├── .env.example # 環境變數範本
└── .dev.vars # 本地開發密鑰(已加入 gitignore)
這種分離方式保持關注點的組織:
src/lib/- 可重複使用的工具src/pages/api/- API 端點supabase/- 資料庫 migrations 和 schema
1. Cloudflare 設定
wrangler.toml 設定
wrangler.toml 檔案設定 Cloudflare 如何建構和部署您的專案:
# Cloudflare Pages 設定
name = "your-project"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
# 建構輸出目錄
pages_build_output_dir = "./dist"
# 環境變數
[vars]
PUBLIC_SUPABASE_URL = "https://your-project.supabase.co"
# 重要:正式環境請將 ANON_KEY 設為 secret:
# wrangler secret put PUBLIC_SUPABASE_ANON_KEY
# 選用:KV Namespace 用於 sessions
# [[kv_namespaces]]
# binding = "SESSION"
# id = "your-kv-namespace-id"
重點說明:
nodejs_compatflag 在 Workers 中啟用 Node.js APIpages_build_output_dir指向您的 Astro 建構輸出- 非敏感變數可以放在
[vars],但密鑰應使用wrangler secret
環境變數
建立兩個檔案來管理密鑰:
# .env.example(提交至 git - 顯示所需變數)
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# .dev.vars(已加入 gitignore - 本地開發的實際密鑰)
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-anon-key
設定正式環境密鑰:
# 透過 Cloudflare CLI 設定密鑰
wrangler secret put PUBLIC_SUPABASE_ANON_KEY
# 或透過 Cloudflare Dashboard:
# Pages > Your Project > Settings > Environment variables
為什麼需要兩個檔案? .env.example 記錄需要哪些變數,但不暴露實際值。.dev.vars 檔案是 Cloudflare 本地開發密鑰的慣例。
2. Supabase 資料庫整合
建立 Supabase 客戶端
客戶端應建立為單例模式以避免多重連線:
// src/lib/supabase.ts
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL || '';
const supabaseAnonKey = import.meta.env.PUBLIC_SUPABASE_ANON_KEY || '';
// 單例模式 - 只在需要時建立客戶端
let supabase: SupabaseClient | null = null;
export function getSupabaseClient(): SupabaseClient | null {
if (!supabaseUrl || !supabaseAnonKey) {
console.warn('Supabase credentials not configured');
return null;
}
if (!supabase) {
supabase = createClient(supabaseUrl, supabaseAnonKey);
}
return supabase;
}
為什麼要優雅降級? 當憑證未設定時回傳 null,您可以在沒有資料庫連線的情況下進行本地開發。應用程式會優雅降級而非崩潰。
型別安全的資料庫函式
為您的資料定義介面並建立型別安全的函式:
// 型別安全介面
export interface Subscriber {
id?: string;
email: string;
source: string;
preferred_language: 'en' | 'zh-tw' | 'ja';
subscribed_at?: string;
confirmed?: boolean;
unsubscribed_at?: string | null;
}
// 具有優雅降級的範例函式
export async function subscribeToNewsletter(
email: string,
source: string = 'website',
language: 'en' | 'zh-tw' | 'ja' = 'en'
): Promise<{ success: boolean; error?: string }> {
// 驗證 email
if (!email || !email.includes('@')) {
return { success: false, error: 'Invalid email address' };
}
const client = getSupabaseClient();
// 當 Supabase 未設定時優雅降級
if (!client) {
console.log('Newsletter subscription (no Supabase):', { email, source, language });
return { success: true }; // 允許在沒有資料庫的情況下開發
}
try {
// 檢查是否已訂閱
const { data: existing } = await client
.from('newsletter_subscribers')
.select('id, unsubscribed_at')
.eq('email', email.toLowerCase())
.single();
if (existing) {
if (existing.unsubscribed_at) {
// 重新訂閱
await client
.from('newsletter_subscribers')
.update({ unsubscribed_at: null, source, preferred_language: language })
.eq('id', existing.id);
}
// 通用成功回應以防止 email 列舉攻擊
return { success: true };
}
// 新訂閱
await client
.from('newsletter_subscribers')
.insert({
email: email.toLowerCase(),
source,
preferred_language: language,
confirmed: true, // 單次確認
});
return { success: true };
} catch (err) {
console.error('Newsletter subscription error:', err);
return { success: false, error: 'Failed to subscribe. Please try again.' };
}
}
安全考量:
- Email 正規化 - 始終將 email 轉為小寫以防止重複
- 通用回應 - 無論 email 是否存在都回傳相同回應,以防止列舉攻擊
- 重新訂閱處理 - 允許使用者在先前取消訂閱後重新訂閱
資料庫 Migrations
使用適當的約束和安全性建立您的表格:
-- supabase/migrations/001_create_newsletter.sql
CREATE TABLE IF NOT EXISTS newsletter_subscribers (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
source TEXT DEFAULT 'website',
preferred_language VARCHAR(10) DEFAULT 'en' CHECK (preferred_language IN ('en', 'zh-tw', 'ja')),
subscribed_at TIMESTAMPTZ DEFAULT NOW(),
confirmed BOOLEAN DEFAULT FALSE,
unsubscribed_at TIMESTAMPTZ
);
-- 加快查詢的索引
CREATE INDEX IF NOT EXISTS idx_newsletter_email ON newsletter_subscribers(email);
CREATE INDEX IF NOT EXISTS idx_newsletter_source ON newsletter_subscribers(source);
-- 啟用 Row Level Security(正式環境必要)
ALTER TABLE newsletter_subscribers ENABLE ROW LEVEL SECURITY;
-- 政策:允許匿名插入(用於註冊)
CREATE POLICY "Allow anonymous inserts" ON newsletter_subscribers
FOR INSERT TO anon
WITH CHECK (true);
-- 政策:拒絕匿名使用者讀取(保護 email 清單)
-- 只有經過驗證的使用者或 service role 可以讀取
CREATE POLICY "Deny anonymous reads" ON newsletter_subscribers
FOR SELECT TO anon
USING (false);
為什麼需要 Row Level Security (RLS)?
沒有 RLS,任何持有您 anon key 的人都可以讀取您的整個訂閱者清單。RLS 確保:
- 匿名使用者只能插入(註冊)
- 匿名使用者無法讀取表格(保護您的 email 清單)
- 只有經過驗證的使用者或 service role 可以查詢資料
執行 Migrations:
# 透過 Supabase Dashboard
# 前往:SQL Editor > New Query > 貼上並執行
# 或透過 Supabase CLI
supabase db push
3. 安全模式
速率限制
使用記憶體內速率限制保護您的 API 端點免受濫用:
// src/lib/rate-limit.ts
interface RateLimitEntry {
timestamps: number[];
blockedUntil?: number;
}
// 記憶體內儲存(每個實例)
const rateLimitStore = new Map<string, RateLimitEntry>();
export interface RateLimitConfig {
windowMs: number;
maxRequests: number;
blockDurationMs?: number;
}
// 預設設定
export const RATE_LIMIT_CONFIGS = {
strict: {
windowMs: 60 * 1000, // 1 分鐘
maxRequests: 5, // 5 個請求
blockDurationMs: 5 * 60 * 1000, // 封鎖 5 分鐘
},
standard: {
windowMs: 60 * 1000,
maxRequests: 30,
blockDurationMs: 60 * 1000,
},
lenient: {
windowMs: 60 * 1000,
maxRequests: 100,
},
} as const;
// 取得客戶端 IP(Cloudflare 提供)
export function getClientId(request: Request): string {
// CF-Connecting-IP 是 Cloudflare 上的真實客戶端 IP
const cfIp = request.headers.get('CF-Connecting-IP');
if (cfIp) return cfIp;
const xForwardedFor = request.headers.get('X-Forwarded-For');
if (xForwardedFor) return xForwardedFor.split(',')[0].trim();
return 'unknown';
}
為什麼使用滑動視窗? 簡單的計數器在固定間隔重置,允許在邊界處產生突發流量。滑動視窗追蹤個別請求的時間戳,實現更平滑的速率限制。
export interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
retryAfter?: number;
}
export function checkRateLimit(
clientId: string,
endpoint: string,
config: RateLimitConfig
): RateLimitResult {
const now = Date.now();
const key = `${clientId}:${endpoint}`;
let entry = rateLimitStore.get(key);
// 檢查是否被封鎖
if (entry?.blockedUntil && entry.blockedUntil > now) {
return {
allowed: false,
remaining: 0,
resetAt: entry.blockedUntil,
retryAfter: Math.ceil((entry.blockedUntil - now) / 1000),
};
}
if (!entry) {
entry = { timestamps: [] };
rateLimitStore.set(key, entry);
}
// 清除舊的時間戳
const windowStart = now - config.windowMs;
entry.timestamps = entry.timestamps.filter(ts => ts > windowStart);
// 檢查限制
if (entry.timestamps.length >= config.maxRequests) {
if (config.blockDurationMs) {
entry.blockedUntil = now + config.blockDurationMs;
}
return {
allowed: false,
remaining: 0,
resetAt: entry.blockedUntil || (entry.timestamps[0] + config.windowMs),
retryAfter: Math.ceil(config.windowMs / 1000),
};
}
entry.timestamps.push(now);
return {
allowed: true,
remaining: config.maxRequests - entry.timestamps.length,
resetAt: now + config.windowMs,
};
}
export function rateLimitResponse(result: RateLimitResult): Response {
return new Response(
JSON.stringify({ error: 'Too many requests', retryAfter: result.retryAfter }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': (result.retryAfter || 60).toString(),
},
}
);
}
CSRF 保護
跨站請求偽造保護驗證請求來自您的網域:
// src/lib/security.ts
const ALLOWED_ORIGINS = [
'https://your-domain.com',
'https://www.your-domain.com',
];
// 開發環境加入 localhost
if (import.meta.env.DEV) {
ALLOWED_ORIGINS.push(
'http://localhost:4321',
'http://localhost:3000'
);
}
export function validateOrigin(request: Request): boolean {
const origin = request.headers.get('Origin');
const referer = request.headers.get('Referer');
// 開發環境較寬鬆
if (import.meta.env.DEV) return true;
// 檢查 Origin header
if (origin) {
return ALLOWED_ORIGINS.includes(origin);
}
// 退而使用 Referer
if (referer) {
try {
const refererUrl = new URL(referer);
return ALLOWED_ORIGINS.includes(refererUrl.origin);
} catch {
return false;
}
}
return false; // 沒有來源資訊則拒絕
}
export function csrfForbiddenResponse(): Response {
return new Response(
JSON.stringify({ error: 'Forbidden' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
export const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Cache-Control': 'no-store, max-age=0',
};
為什麼同時使用 Origin 和 Referer? 某些瀏覽器或設定可能不會在同源請求中發送 Origin header。Referer header 提供了備用方案。
4. API 路由(Edge Functions)
完整 API 路由範例
將所有安全元素整合到一個安全的 API 端點:
// src/pages/api/newsletter.ts
import type { APIRoute } from 'astro';
import { subscribeToNewsletter } from '../../lib/supabase';
import { checkRateLimit, getClientId, rateLimitResponse, RATE_LIMIT_CONFIGS } from '../../lib/rate-limit';
import { validateOrigin, csrfForbiddenResponse, SECURITY_HEADERS } from '../../lib/security';
// 確保伺服器端渲染(非靜態)
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
// 1. CSRF 保護
if (!validateOrigin(request)) {
return csrfForbiddenResponse();
}
// 2. 速率限制
const clientId = getClientId(request);
const rateLimitResult = checkRateLimit(clientId, 'newsletter', RATE_LIMIT_CONFIGS.strict);
if (!rateLimitResult.allowed) {
return rateLimitResponse(rateLimitResult);
}
// 3. 處理請求
try {
const { email, source = 'website', language = 'en' } = await request.json();
if (!email) {
return new Response(
JSON.stringify({ error: 'Email is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
}
const result = await subscribeToNewsletter(email, source, language);
if (result.success) {
return new Response(
JSON.stringify({ message: 'Successfully subscribed!' }),
{ status: 200, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
} else {
return new Response(
JSON.stringify({ error: result.error }),
{ status: 400, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
}
} catch (error) {
console.error('Newsletter API error:', error);
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{ status: 500, headers: { 'Content-Type': 'application/json', ...SECURITY_HEADERS } }
);
}
};
安全層級順序:
- CSRF 保護 - 驗證請求來源
- 速率限制 - 防止濫用
- 輸入驗證 - 確保必要欄位
- 錯誤處理 - 絕不洩漏內部錯誤
prerender = false 匯出是關鍵 - 它告訴 Astro 將此作為伺服器端函式執行,而非產生靜態頁面。
5. 進階模式
活動報名與 QR Code
用於更複雜的使用案例,如活動報名:
// 產生加密安全的 QR token
function generateQRToken(): string {
return crypto.randomUUID();
}
// 報名並處理重複
export async function registerForEvent(
eventSlug: string,
email: string,
name: string
): Promise<{ success: boolean; registration?: EventRegistration }> {
const client = getSupabaseClient();
if (!client) return { success: false };
// 檢查現有報名
const { data: existing } = await client
.from('event_registrations')
.select('*')
.eq('event_slug', eventSlug)
.eq('email', email.toLowerCase())
.single();
if (existing) {
// 回傳現有資料(良好的使用者體驗,防止列舉)
return { success: true, registration: existing };
}
// 新報名
const { data, error } = await client
.from('event_registrations')
.insert({
event_slug: eventSlug,
email: email.toLowerCase(),
name,
qr_code_token: generateQRToken(),
status: 'registered',
})
.select()
.single();
if (error) throw error;
return { success: true, registration: data };
}
注重隱私的分析
追蹤使用情況而不儲存個人資料:
-- 注重隱私設計的分析表格
CREATE TABLE analytics_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
event_type TEXT NOT NULL,
event_data JSONB DEFAULT '{}',
client_ip_hash TEXT, -- 雜湊過的,非原始 IP
user_agent TEXT,
country TEXT DEFAULT 'unknown', -- 來自 CF-IPCountry header
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 啟用 RLS
ALTER TABLE analytics_events ENABLE ROW LEVEL SECURITY;
-- 允許匿名插入(追蹤),拒絕讀取
CREATE POLICY "Allow anonymous tracking" ON analytics_events
FOR INSERT TO anon WITH CHECK (true);
// 從 Cloudflare header 取得國家
function getCountry(request: Request): string {
return request.headers.get('CF-IPCountry') || 'unknown';
}
// 雜湊 IP 以保護隱私
async function hashIP(ip: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(ip + 'your-salt');
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
}
為什麼要雜湊 IP? 儲存原始 IP 會產生隱私疑慮和潛在的 GDPR 問題。使用 salt 進行雜湊讓您可以追蹤不重複訪客,而不儲存可識別的資訊。
6. 部署
Astro 設定
設定 Astro 以進行 Cloudflare 部署:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
export default defineConfig({
output: 'hybrid', // 預設靜態,可選擇 SSR
adapter: cloudflare({
platformProxy: {
enabled: true,
},
}),
integrations: [react(), tailwind()],
});
為什麼選擇 hybrid 模式? 大多數頁面可以是靜態的(更快、更便宜),而 API 路由和動態頁面使用 SSR。這讓您兩全其美。
部署指令
# 1. 安裝相依套件
pnpm install
# 2. 建構
pnpm build
# 3. 本地預覽(使用 Cloudflare 環境)
pnpm preview # 或:wrangler pages dev ./dist
# 4. 部署
# 選項 A:在 Cloudflare Dashboard 連結 GitHub repo(推薦)
# 選項 B:直接部署
wrangler pages deploy ./dist
正式環境檢查清單
上線前,請確認:
- 透過
wrangler secret put設定 Supabase 密鑰 - 在所有表格上啟用 RLS
- 在 security.ts 中設定允許的來源
- 設定 Cloudflare Rate Limiting Rules(Dashboard)
- 啟用 Cloudflare WAF 規則
- 設定自訂網域與 SSL
7. 疑難排解
”Supabase credentials not configured”
# 檢查環境變數是否已設定
wrangler pages dev ./dist --local
# 為正式環境設定密鑰
wrangler secret put PUBLIC_SUPABASE_URL
wrangler secret put PUBLIC_SUPABASE_ANON_KEY
RLS 政策阻擋查詢
-- 除錯:檢查目前的政策
SELECT * FROM pg_policies WHERE tablename = 'your_table';
-- 暫時停用 RLS(僅限開發環境!)
ALTER TABLE your_table DISABLE ROW LEVEL SECURITY;
正式環境速率限制無效
記憶體內速率限制器是每個實例運作的。對於多實例部署:
- 使用 Cloudflare Rate Limiting Rules(Dashboard)- 正式環境推薦
- 或使用 Cloudflare KV 在實例間共享狀態
- 或使用 Supabase 進行速率限制追蹤
安全最佳實踐摘要
- Row Level Security (RLS) - 始終在 Supabase 表格上啟用
- CSRF 保護 - 在所有 POST 端點驗證 Origin/Referer headers
- 速率限制 - 嚴格保護註冊/驗證端點
- 輸入驗證 - 驗證並清理所有輸入
- 錯誤處理 - 絕不向客戶端洩漏內部錯誤
- 密鑰管理 - 正式環境使用
wrangler secret,絕不提交密鑰
開始使用
今天:
- 建立 Supabase 專案(免費方案)
- 設定 Cloudflare Pages(連結您的 GitHub repo)
- 設定環境變數
本週:
- 實作您的第一個具有安全層級的 API 端點
- 建立具有 RLS 的資料庫表格
- 使用
wrangler pages dev進行本地測試
本月:
- 新增進階功能(驗證、檔案上傳等)
- 設定 Cloudflare WAF 和速率限制
- 設定監控和分析
本指南基於 claude-world.com 的實際實作,使用 Claude Code 在 48 小時內從零建構到正式上線。
資源: