メインコンテンツへスキップ
注目 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データベース + 認証無料枠
AstroSSR対応の静的サイトジェネレーター無料

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マイグレーション
├── wrangler.toml            # Cloudflare設定
├── .env.example             # 環境変数テンプレート
└── .dev.vars                # ローカル開発用シークレット(gitignore対象)

この分離により関心事が整理されます:

  • src/lib/ - 再利用可能なユーティリティ
  • src/pages/api/ - APIエンドポイント
  • supabase/ - データベースマイグレーションとスキーマ

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をシークレットとして設定:
# wrangler secret put PUBLIC_SUPABASE_ANON_KEY

# オプション: セッション用KV Namespace
# [[kv_namespaces]]
# binding = "SESSION"
# id = "your-kv-namespace-id"

重要なポイント:

  1. nodejs_compatフラグでWorkersでNode.js APIが有効になる
  2. pages_build_output_dirはAstroのビルド出力を指す
  3. 機密性のない変数は[vars]に記述可能だが、シークレットはwrangler secretを使用すべき

環境変数

シークレット管理用に2つのファイルを作成:

# .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 > プロジェクト > Settings > Environment variables

なぜ2つのファイル? .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 }> {
  // メール検証
  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);
      }
      // メール列挙を防ぐための汎用的な成功レスポンス
      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. メールの正規化 - 重複を防ぐため常に小文字化
  2. 汎用的なレスポンス - 列挙攻撃を防ぐため、メールが存在するかどうかに関わらず同じレスポンスを返す
  3. 再登録処理 - 以前退会したユーザーの再登録を許可

データベースマイグレーション

適切な制約とセキュリティを備えたテーブルを作成:

-- 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;

-- ポリシー: 匿名でのINSERTを許可(登録用)
CREATE POLICY "Allow anonymous inserts" ON newsletter_subscribers
  FOR INSERT TO anon
  WITH CHECK (true);

-- ポリシー: 匿名ユーザーの読み取りを拒否(メールリストを保護)
-- 認証済み/サービスロールのみ読み取り可能
CREATE POLICY "Deny anonymous reads" ON newsletter_subscribers
  FOR SELECT TO anon
  USING (false);

なぜRow Level Security (RLS)?

RLSがないと、anonキーを持つ誰もが購読者リスト全体を読み取れてしまいます。RLSは以下を保証します:

  • 匿名ユーザーはINSERT(登録)のみ可能
  • 匿名ユーザーはテーブルを読み取れない(メールリストを保護)
  • 認証済みユーザーまたはサービスロールのみがデータをクエリ可能

マイグレーションの実行:

# 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保護

Cross-Site Request Forgery保護は、リクエストがあなたのドメインから来ていることを検証します:

// 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ヘッダーを確認
  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ヘッダーが送信されない場合があります。Refererヘッダーはフォールバックとして機能します。

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コード付きイベント登録

イベント登録などのより複雑なユースケース:

// 暗号学的に安全なQRトークンを生成
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) {
    // 既存を返す(良いUX、列挙を防止)
    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ヘッダーから
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- RLSを有効化
ALTER TABLE analytics_events ENABLE ROW LEVEL SECURITY;

-- 匿名でのINSERT(トラッキング)を許可、読み取りは拒否
CREATE POLICY "Allow anonymous tracking" ON analytics_events
  FOR INSERT TO anon WITH CHECK (true);
// CloudflareヘッダーからCountryを取得
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の問題が生じます。ソルト付きでハッシュ化することで、識別可能な情報を保存せずにユニーク訪問者を追跡できます。

6. デプロイ

Astro設定

Cloudflareデプロイ用にAstroを設定:

// 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()],
});

なぜハイブリッドモード? ほとんどのページは静的(より高速、低コスト)にでき、APIルートや動的ページはSSRを使用します。これにより両方の利点が得られます。

デプロイコマンド

# 1. 依存関係をインストール
pnpm install

# 2. ビルド
pnpm build

# 3. ローカルでプレビュー(Cloudflare環境で)
pnpm preview  # または: wrangler pages dev ./dist

# 4. デプロイ
# オプションA: Cloudflare DashboardでGitHubリポジトリを接続(推奨)
# オプション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ヘッダーを検証
  3. レート制限 - サインアップ/認証エンドポイントを厳格に保護
  4. 入力検証 - すべての入力を検証してサニタイズ
  5. エラーハンドリング - 内部エラーをクライアントに漏らさない
  6. シークレット管理 - 本番ではwrangler secretを使用、シークレットをコミットしない

はじめる

今日:

  1. Supabaseプロジェクトを作成(無料枠)
  2. Cloudflare Pagesをセットアップ(GitHubリポジトリを接続)
  3. 環境変数を設定

今週:

  1. セキュリティレイヤー付きの最初のAPIエンドポイントを実装
  2. RLS付きデータベーステーブルを作成
  3. wrangler pages devでローカルテスト

今月:

  1. 高度な機能を追加(認証、ファイルアップロードなど)
  2. Cloudflare WAFとレート制限を設定
  3. モニタリングとアナリティクスをセットアップ

このガイドは、Claude Codeを使用して48時間でゼロから本番環境まで構築したclaude-world.comの実際の実装に基づいています。


リソース: