跳至主要內容
精選 Supabase Cloudflare PostgreSQL Edge Functions Security

Supabase + Cloudflare Pages:零成本全端部署

使用 Supabase(免費 PostgreSQL)和 Cloudflare Pages(免費託管)建構生產等級的全端應用程式。完整指南涵蓋資料庫設定、Edge Functions、安全模式及部署。

2026年1月16日 18 分鐘閱讀 作者:Claude World

社群模式說明: 本指南記錄了建構 claude-world.com 所使用的實際實作模式。這些模式經過生產環境測試,可以根據您的專案需求進行調整。

建構全端應用程式通常需要支付託管、資料庫和基礎設施的費用。但透過正確的服務組合,您可以將生產等級的應用程式部署成本降至每月 $0。

本指南涵蓋完整的 Supabase + Cloudflare Pages 整合模式,包含資料庫設定、Edge Functions、安全強化及部署。

為什麼選擇這個技術棧?

元件用途費用
Cloudflare Pages靜態託管 + Edge Functions免費
SupabasePostgreSQL 資料庫 + 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"

重點說明:

  1. nodejs_compat flag 在 Workers 中啟用 Node.js API
  2. pages_build_output_dir 指向您的 Astro 建構輸出
  3. 非敏感變數可以放在 [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.' };
  }
}

安全考量:

  1. Email 正規化 - 始終將 email 轉為小寫以防止重複
  2. 通用回應 - 無論 email 是否存在都回傳相同回應,以防止列舉攻擊
  3. 重新訂閱處理 - 允許使用者在先前取消訂閱後重新訂閱

資料庫 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 } }
    );
  }
};

安全層級順序:

  1. CSRF 保護 - 驗證請求來源
  2. 速率限制 - 防止濫用
  3. 輸入驗證 - 確保必要欄位
  4. 錯誤處理 - 絕不洩漏內部錯誤

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;

正式環境速率限制無效

記憶體內速率限制器是每個實例運作的。對於多實例部署:

  1. 使用 Cloudflare Rate Limiting Rules(Dashboard)- 正式環境推薦
  2. 或使用 Cloudflare KV 在實例間共享狀態
  3. 或使用 Supabase 進行速率限制追蹤

安全最佳實踐摘要

  1. Row Level Security (RLS) - 始終在 Supabase 表格上啟用
  2. CSRF 保護 - 在所有 POST 端點驗證 Origin/Referer headers
  3. 速率限制 - 嚴格保護註冊/驗證端點
  4. 輸入驗證 - 驗證並清理所有輸入
  5. 錯誤處理 - 絕不向客戶端洩漏內部錯誤
  6. 密鑰管理 - 正式環境使用 wrangler secret,絕不提交密鑰

開始使用

今天:

  1. 建立 Supabase 專案(免費方案)
  2. 設定 Cloudflare Pages(連結您的 GitHub repo)
  3. 設定環境變數

本週:

  1. 實作您的第一個具有安全層級的 API 端點
  2. 建立具有 RLS 的資料庫表格
  3. 使用 wrangler pages dev 進行本地測試

本月:

  1. 新增進階功能(驗證、檔案上傳等)
  2. 設定 Cloudflare WAF 和速率限制
  3. 設定監控和分析

本指南基於 claude-world.com 的實際實作,使用 Claude Code 在 48 小時內從零建構到正式上線。


資源: