言語を切り替える
テーマを切り替える

Supabase Storage 実践ガイド:ファイルアップロード、CDN、アクセス制御

先週、読者からこんな質問がありました。「ユーザーのアバターアップロード機能、S3 と Cloudflare R2 のどちらがいいですか?」

正直なところ、私は少し考え込みました。実はこの問題、2つのプロジェクトで苦労した経験があります。S3 の権限ポリシー設定は本当に複雑で、R2 は安価ですが独自の認証システムを構築する必要があります。その後、私はプロジェクトを Supabase Storage に移行しました。「銀の弾丸」だからではなく、既に Supabase の Auth とデータベースを使っているなら、Storage との組み合わせが本当にスムーズだからです。

この記事では、Supabase Storage のコアメカニズム、3つのアクセス制御モード、大容量ファイルアップロードの落とし穴、CDN 最適化テクニック、そして R2/S3 とのコスト比較をすべて解説します。紹介するコードはすべてそのまま実行可能です。


一、Supabase Storage のコアアーキテクチャ

まず明確にしておきましょう:Supabase Storage の基盤は AWS S3 です。

しかし、非常に薄いラッパーで包まれており、JavaScript SDK で直接操作でき、AWS の複雑な認証情報や IAM ポリシーを気にする必要がありません。

Auth との自動連携

これが私が最も気に入っている点です。Supabase でバケットを作成し、supabase.auth.getUser() で取得した JWT トークンを使って、誰がアップロードできて誰がダウンロードできるかを制御できます。別途権限システムを構築する必要がありません。

// アップロード時に自動的にユーザー ID を付与
const { data, error } = await supabase.storage
  .from('avatars')
  .upload('user-123/profile.jpg', file)

バックエンドでは RLS(Row Level Security)ポリシーがチェックされます。後ほど設定方法を詳しく解説します。

グローバル CDN の自動適用

アップロードされたファイルは自動的に Cloudflare CDN 経由で配信されます。CloudFront や Cloudflare Workers を自分で設定する必要はありません。

ブラウザの開発者ツールを開き、レスポンスヘッダーの cf-cache-status を確認してください:

cf-cache-status: HIT

HIT はキャッシュヒット、MISS はキャッシュミスを意味します。Public bucket のキャッシュヒット率は通常、Private bucket より大幅に高くなります。これは Private bucket のキャッシュポリシーがより厳格だからです。

Smart CDN:キャッシュの「自動無効化」メカニズム

これは Supabase の大きな強みです。従来の CDN では、ファイルを更新した後、手動でキャッシュをパージするか、TTL の期限を待つ必要がありました。Supabase の Smart CDN は、ファイルのメタデータを自動的にエッジノードに同期し、ファイルが更新されると最大 60 秒で世界中に反映されます。

ただし、喜ぶのはまだ早いです。60 秒は、リアルタイム性が求められるシナリオではかなり長く感じられます。即座に反映する必要がある場合は、後述する cacheNonce パラメータを使用してください。


二、3つのアクセス制御モードの比較

ここが多くの人が混乱する部分です。Public bucket、Private bucket、Signed URL。3つのモードがあり、3つの異なる使用シナリオがあります。選択を間違えると、キャッシュヒット率が悲惨なほど低くなるか、セキュリティ問題が発生します。

3種類
アクセス制御モード

2.1 Public Bucket:公開リソースの最適解

ファイルがもともと誰でも見られるものなら、ウェブサイトのロゴ、ブログの画像、公開ドキュメントなど、直接 Public bucket を使用します。

メリット

  • URL が最もシンプル:https://xxx.supabase.co/storage/v1/object/public/bucket-name/file.jpg
  • キャッシュヒット率が最も高く、CDN が直接応答、Auth 検証を経由しない
  • コードが最もシンプル、1行で完了
// Public URL を取得
const { data } = supabase.storage
  .from('public-images')
  .getPublicUrl('hero-banner.jpg')

console.log(data.publicUrl)
// https://xxx.supabase.co/storage/v1/object/public/public-images/hero-banner.jpg

適したシナリオ

  • ユーザーアバター(公開表示されるもの)
  • ブログの画像
  • ウェブサイトの静的リソース
  • 公開ドキュメント

2.2 Private Bucket + Signed URL:プライベートファイルの標準ソリューション

一部のファイルは公開できません。ユーザーがアップロードした契約書、会員限定コンテンツ、機密ドキュメントなど。この場合、Private bucket を使用し、有効期限付きの Signed URL を生成します。

// 1時間有効なアクセスリンクを生成
const { data, error } = await supabase.storage
  .from('private-docs')
  .createSignedUrl('contracts/user-123.pdf', 3600) // 3600秒 = 1時間

console.log(data.signedUrl)
// https://xxx.supabase.co/storage/v1/object/sign/private-docs/contracts/user-123.pdf?token=xxx

注意点:Signed URL は毎回生成されるたびに異なります。これによりキャッシュヒット率に影響します。頻繁に新しい Signed URL を生成すると、CDN は常に MISS を返します。

最適化テクニック:同じユーザーが短時間に同じファイルに複数回アクセスする場合、Signed URL をフロントエンドまたは Redis にキャッシュし、毎回再生成しないようにしてください。

2.3 RLS ポリシー:詳細な権限制御

これが最も強力でありながら、最も見落とされがちな機能です。storage.objects テーブルに RLS ポリシーを定義し、誰がどのファイルを操作できるかを正確に制御できます。

シナリオ1:ユーザーは自分のフォルダにのみアップロード可能

-- storage.objects テーブルにポリシーを作成
CREATE POLICY "Users can upload to own folder"
ON storage.objects FOR INSERT
WITH CHECK (
  bucket_id = 'avatars' 
  AND auth.uid()::text = (storage.foldername(name))[1]
);

-- 解説:name はファイルの完全なパス(例:'user-123/avatar.jpg')
-- storage.foldername(name)[1] は最初のフォルダ名を取得、つまり 'user-123'
-- auth.uid() は現在ログインしているユーザーの ID
-- 両者が一致した場合のみアップロードを許可

シナリオ2:管理者はすべてのファイルにアクセス可能

CREATE POLICY "Admins can access all"
ON storage.objects FOR ALL
USING (
  auth.jwt() ->> 'role' = 'admin'
);

シナリオ3:会員のみ特定のコンテンツをダウンロード可能

CREATE POLICY "Members can download premium content"
ON storage.objects FOR SELECT
USING (
  bucket_id = 'premium-content'
  AND EXISTS (
    SELECT 1 FROM user_subscriptions
    WHERE user_id = auth.uid()
    AND status = 'active'
  )
);

この3つのポリシーを組み合わせることで、ほとんどのビジネスシナリオをカバーできます。


三、ファイルアップロード実践

いよいよ実践編です。

3.1 標準アップロード:小さなファイルを素早く処理

5MB 未満のファイルは、直接 upload メソッドを使用できます。

// React アップロードコンポーネント
import { useState } from 'react'
import { supabase } from './supabase-client'

export function AvatarUpload() {
  const [uploading, setUploading] = useState(false)
  const [avatarUrl, setAvatarUrl] = useState<string | null>(null)

  const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return

    setUploading(true)
    
    const fileExt = file.name.split('.').pop()
    const fileName = `${Date.now()}.${fileExt}`
    const filePath = `avatars/${fileName}`

    const { error } = await supabase.storage
      .from('public-images')
      .upload(filePath, file, {
        cacheControl: '3600', // ブラウザキャッシュ 1時間
        upsert: false // 既存ファイルを上書きしない
      })

    if (error) {
      alert('アップロード失敗:' + error.message)
    } else {
      const { data } = supabase.storage
        .from('public-images')
        .getPublicUrl(filePath)
      setAvatarUrl(data.publicUrl)
    }

    setUploading(false)
  }

  return (
    <div>
      <input 
        type="file" 
        accept="image/*" 
        onChange={handleUpload}
        disabled={uploading}
      />
      {avatarUrl && <img src={avatarUrl} alt="avatar" />}
      {uploading && <p>アップロード中...</p>}
    </div>
  )
}

いくつかのポイント:

  • cacheControl はブラウザキャッシュ時間を設定、CDN キャッシュとは別物
  • upsert: false で誤って上書きしないように保護、上書きする場合は true に変更
  • ファイル名にタイムスタンプを使用して重複を回避、UUID も可

3.2 TUS 分割アップロード:大容量ファイルの安定ソリューション

5MB を超えるファイルや、ネットワークが不安定な環境では、TUS 分割アップロードを使用します。

重要な制約chunkSize は必ず 6MB に設定してください。変更できません。これは Supabase のハードコードされた制限です。

有効期限:アップロード URL は24時間有効、期限切れの場合は再生成が必要です。

まず依存関係をインストール:

npm install tus-js-client uppy @uppy/core @uppy/dashboard @uppy/tus

完全なコード:

import Uppy from '@uppy/core'
import { Dashboard } from '@uppy/react'
import Tus from '@uppy/tus'
import { supabase } from './supabase-client'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'

export function LargeFileUploader() {
  const uppy = new Uppy({
    restrictions: {
      maxFileSize: 100 * 1024 * 1024, // 100MB
      allowedFileTypes: ['video/*', 'image/*']
    }
  })

  // Supabase のセッショントークンを取得
  const getSession = async () => {
    const { data: { session } } = await supabase.auth.getSession()
    return session?.access_token || ''
  }

  uppy.use(Tus, {
    endpoint: 'https://xxx.supabase.co/storage/v1/upload/resumable',
    chunkSize: 6 * 1024 * 1024, // 必ず 6MB
    async onBeforeRequest(req) {
      const token = await getSession()
      req.setHeader('Authorization', `Bearer ${token}`)
    },
    onAfterResponse(req, res) {
      // アップロード完了後にファイルパスを取得
      const location = res.getHeader('Location')
      console.log('File uploaded to:', location)
    }
  })

  return (
    <div style={{ maxWidth: '600px', margin: '0 auto' }}>
      <Dashboard uppy={uppy} />
    </div>
  )
}

トラブルシューティング:分割アップロードが 6MB で止まってしまう場合、以下を確認:

  1. chunkSize が 6MB か(正確に設定する必要があります)
  2. トークンが期限切れでないか(24時間の有効期限)
  3. RLS ポリシーが INSERT を許可しているか(GitHub Issue #563 を参照)

3.3 Presigned Upload URL:サードパーティアップロード認証

ユーザーに直接アップロードさせたいが、service_role key を公開したくない場合があります。createSignedUploadUrl で事前署名付きアップロード URL を生成します。

// サーバーサイドでアップロード URL を生成
const { data, error } = await supabase.storage
  .from('user-uploads')
  .createSignedUploadUrl('documents/report.pdf')

// data.signedUrl をフロントエンドに渡して直接アップロード
// フロントエンドは service_role key を知る必要がない

四、CDN と画像最適化

4.1 Smart CDN メカニズムの詳細

前述の通り、Smart CDN はファイル更新後に自動的にキャッシュを無効化します。しかし、60秒の伝播遅延は待ちきれない場合もあります。

ベストプラクティス

  1. 頻繁に更新されるファイルは新しいパスにアップロード
// 毎回同じファイルを更新しない
await storage.from('images').upload('logo.png', file, { upsert: true })

// この方法:毎回新しいファイル名を生成
const version = Date.now()
await storage.from('images').upload(`logo-${version}.png`, file)
  1. cacheNonce でキャッシュを強制的にバイパス
const { data } = supabase.storage
  .from('images')
  .getPublicUrl('logo.png', {
    cacheNonce: Date.now().toString() // 毎回異なる値
  })
  1. Signed URL の再利用

同じユーザーが同じファイルにアクセスする場合、Signed URL を保存し、毎回再生成しないようにします。

4.2 画像変換と自動最適化

Supabase はリアルタイム画像変換をサポートしています。幅、高さ、品質、フォーマットすべて調整可能です。

制限

  • 幅・高さ:1-2500px
  • ファイルサイズ:≤25MB
  • 解像度:≤50MP
// サムネイルを取得
const { data } = supabase.storage
  .from('images')
  .getPublicUrl('hero.jpg', {
    transform: {
      width: 300,
      height: 200,
      resize: 'cover', // または 'contain'、'fill'
      quality: 80,
      format: 'webp' // 自動的に WebP に変換
    }
  })

Next.js 画像ローダー統合

// next.config.js
module.exports = {
  images: {
    loader: 'custom',
    loaderFile: './lib/supabase-image-loader.js'
  }
}
// lib/supabase-image-loader.js
export default function supabaseLoader({ src, width, quality }) {
  const url = new URL(src)
  url.searchParams.set('width', width.toString())
  url.searchParams.set('quality', (quality || 75).toString())
  url.searchParams.set('format', 'webp')
  return url.toString()
}

課金:画像変換は $5/1000枚のオリジナル画像です。大量の変換(レスポンシブ画像の複数サイズなど)がある場合、このコストを考慮してください。


五、コスト比較と選択のアドバイス

多くの人が気になる部分です。主要なソリューションを比較してみましょう。

5.1 価格比較表

サービスストレージ料金出力料金無料枠特徴
Supabase StorageS3 ベース料金CDN 別課金Pro プランに含まれるAuth 統合、RLS
Cloudflare R2$0.015/GBゼロ10GB + 100万操作出力料金ゼロ
AWS S3$0.023/GB$0.09/GB5GB/12ヶ月エコシステム最強
DigitalOcean Spaces$5/250GB含まれるなし固定料金
$0
Cloudflare R2 出力料金

5.2 選択の決定

高ダウンロード量シナリオ → R2

ファイルが頻繁にダウンロードされる場合(画像共有サイト、動画ホスティングなど)、R2 の出力料金ゼロで大幅なコスト削減が可能です。S3 の出力料金は GB あたり約 10 セント、トラフィックが増えると痛い出費になります。

Auth 統合が必要 → Supabase Storage

既に Supabase の Auth とデータベースを使用している場合、Storage との統合はスムーズです。ユーザー権限制御、RLS ポリシーを直接再利用できます。

AWS エコシステムのヘビーユーザー → S3

Lambda、CloudFront、S3 Select、S3 Glacier。アーキテクチャが既に AWS に依存している場合、乗り換えのコストが節約額を上回る可能性があります。

固定予算、トラフィック予測可能 → DigitalOcean Spaces

月額固定料金で、従量課金を気にしたくない小規模プロジェクトに適しています。

5.3 コスト最適化のアドバイス

  1. ライフサイクルポリシー:古いファイルを自動的に Glacier にアーカイブ
  2. 画像圧縮:アップロード前に圧縮、または Supabase の画像変換を使用
  3. Public bucket でキャッシュヒット率を向上:公開可能なものは公開に
  4. Signed URL の再利用:重複生成によるキャッシュ MISS を削減

六、よくある問題のトラブルシューティング

6.1 ファイル更新後も古いバージョンのまま

原因:Smart CDN の60秒の伝播遅延。

解決策

  • 60秒待つ
  • 新しいパスにアップロード
  • cacheNonce でキャッシュをバイパス

6.2 分割アップロードが 6MB で止まる

原因chunkSize の設定エラー。

解決策chunkSize: 6 * 1024 * 1024 が正確に設定されていることを確認。

// 誤り:5MB と記述
chunkSize: 5 * 1024 * 1024 // 止まってしまう

// 正解:必ず 6MB
chunkSize: 6 * 1024 * 1024

6.3 アップロードが 403 Forbidden を返す

原因:RLS ポリシーが正しく設定されていない。

確認手順

  1. バケットが Public か Private かを確認
  2. storage.objects テーブルの RLS ポリシーを確認
  3. ポリシーが INSERT 操作を許可していることを確認
-- 既存のポリシーを確認
SELECT * FROM pg_policies WHERE tablename = 'objects';

-- アップロードを許可するポリシーを追加
CREATE POLICY "Allow upload"
ON storage.objects FOR INSERT
WITH CHECK (bucket_id = 'your-bucket');

6.4 Signed URL にアクセスできない

原因:URL の期限切れまたはトークンが無効。

解決策

  • 有効期限が適切か確認
  • トークンが切り捨てられていないか確認
  • テスト時は長い有効期限(例:24時間)の URL を最初に生成

まとめ

要点を整理しましょう:

  • Public bucket は公開リソース向け、キャッシュヒット率が最も高い
  • Private bucket + Signed URL はプライベートファイル向け、キャッシュポリシーに注意
  • RLS ポリシー は詳細な権限制御を提供、この機能を見逃さないで
  • TUS 分割アップロード で大容量ファイルを処理、chunkSize は必ず 6MB
  • Smart CDN は自動的にキャッシュを無効化、ただし60秒の遅延あり
  • 選択:高ダウンロード量は R2、Auth 統合は Supabase Storage、AWS エコシステムは S3

既に Supabase のデータベースと認証を使用している場合、Storage は自然な選択です。しかし、単にオブジェクトストレージが必要で、トラフィックが多く、予算に敏感な場合、R2 の出力料金ゼロは確かに魅力的です。

Supabase Storage について、他に質問や経験した落とし穴があれば、コメント欄で交流しましょう。

Supabase Storage ファイルアップロード完全フロー

バケット作成から RLS ポリシー設定まで、安全で制御可能なファイルアップロードを実現

⏱️ 目安時間: 30 分

  1. 1

    ステップ1: Storage バケットを作成

    Supabase Dashboard でバケットを作成:

    • Storage ページに移動し、「Create a new bucket」をクリック
    • バケット名を入力(例:avatars、documents)
    • Public または Private モードを選択
    • Public バケットはファイルに直接アクセス可能、Private は署名付き URL が必要
  2. 2

    ステップ2: RLS ポリシーを設定

    storage.objects テーブルでアクセス制御を定義:

    ```sql
    -- ユーザーは自分のファイルのみ操作可能
    CREATE POLICY "Users manage own files"
    ON storage.objects FOR ALL
    USING (auth.uid()::text = (storage.foldername(name))[1]);
    ```

    • bucket_id でターゲットバケットを指定
    • auth.uid() で現在のユーザー ID を取得
    • storage.foldername() でファイルパスを解析
  3. 3

    ステップ3: 標準アップロードを実装

    upload メソッドで小さなファイル(<5MB)をアップロード:

    ```typescript
    const { error } = await supabase.storage
    .from('bucket-name')
    .upload('path/file.jpg', file, {
    cacheControl: '3600',
    upsert: false
    });
    ```

    • cacheControl でブラウザキャッシュ時間を設定
    • upsert: false で既存ファイルの上書きを防止
  4. 4

    ステップ4: TUS 分割アップロードを設定

    大容量ファイル(>5MB)には TUS プロトコルを使用:

    • 依存関係をインストール:npm install @uppy/tus tus-js-client
    • chunkSize: 6 * 1024 * 1024 に設定(必ず 6MB)
    • Authorization header で JWT トークンを渡すように設定
    • アップロード URL の有効期限は24時間
  5. 5

    ステップ5: CDN キャッシュを最適化

    キャッシュヒット率を向上させる重要なテクニック:

    • Public バケットのキャッシュヒット率が最も高い
    • 頻繁に更新するファイルは上書きではなく新しいパスを使用
    • cacheNonce でキャッシュを強制更新
    • Signed URL はキャッシュして再利用、重複生成を避ける

FAQ

Supabase Storage の chunkSize はなぜ必ず 6MB なのか?
これは Supabase サーバー側のハードコードされた制限です。他の値(例:5MB)に設定すると、アップロードが止まってしまいます。コードで正確に設定してください:chunkSize: 6 * 1024 * 1024。
Public バケットと Private バケットはどう選べばいい?
ファイルのアクセス権限に基づいて選択:

• Public バケット:ウェブサイトの静的リソース、公開画像、ブログの画像—誰でもアクセス可能
• Private バケット:ユーザーのプライベートファイル、会員コンテンツ、機密ドキュメント—Signed URL または RLS ポリシーでアクセス制御が必要
ファイルを更新しても古いバージョンのままなのはなぜ?
Smart CDN のキャッシュ無効化は、世界中のノードに伝播するまで最大60秒かかります。解決策は3つ:60秒待つ、新しいパスにアップロード(推奨)、cacheNonce パラメータでキャッシュを強制的にバイパス。
Supabase Storage と Cloudflare R2 はどちらが安い?
使用シナリオによります:

• 高ダウンロード量:R2 は出力料金ゼロでコスト削減(S3 の出力料金は $0.09/GB)
• Auth 統合必要:Supabase Storage が便利(RLS ポリシーを直接再利用)
• 既に Supabase 使用:Storage がスムーズな選択
• 純粋なオブジェクトストレージ:R2 の方が低コスト
RLS ポリシーでユーザーが自分のファイルにのみアクセスできるように制限するには?
storage.foldername() でファイルパスを解析し、auth.uid() でユーザー ID を照合:

```sql
CREATE POLICY "Users own files"
ON storage.objects FOR ALL
USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);
```

これでユーザーはパスの最初のセグメントが自分の ID のファイルのみ操作可能になります。
Signed URL のキャッシュヒット率が低い場合はどうすればいい?
Signed URL は毎回生成時に異なる値になり、CDN キャッシュ MISS を引き起こします。最適化方法:Signed URL をフロントエンドまたは Redis にキャッシュし、同じユーザーが短時間に再利用できるようにする。または RLS ポリシーでアクセス制御し、Public バケットを使用する方法もある。

参考資料

6 min read · 公開日: 2026年4月14日 · 更新日: 2026年4月14日

シリーズの読書導線 第 5 / 5 記事

Supabase 実践ガイド

検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。

シリーズ全体を見る

関連記事

コメント

GitHubアカウントでログインしてコメントできます