Storage
S3 互換のファイル / 画像ストレージ。バケット単位で公開・非公開を分け、 署名付き URL や画像変換にも対応。RLS と統合される。
主要概念
- Bucket: 名前空間(フォルダのような単位)
- Public bucket: 誰でも URL でアクセスできる
- Private bucket: 認証 + RLS 必須
- Object: バケット内のファイル(パスでツリー構造)
- Signed URL: 期限付きの一時 URL
バケットの作成
Dashboard
Storage → New bucket。Public / Private を選び、ファイルサイズ上限・MIME 制限を設定。
SQL
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true);
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
'documents', 'documents', false,
10485760, -- 10 MB
array['application/pdf', 'image/png']
);
アップロード
クライアント
const file = e.target.files[0]
const { data, error } = await supabase.storage
.from("avatars")
.upload(`${userId}/profile.png`, file, {
cacheControl: "3600",
upsert: true, // 上書き
contentType: file.type,
})
if (error) console.error(error)
console.log(data.path) // "uuid/profile.png"
進捗
// supabase-js には進捗イベントが無い → fetch で代替
// もしくは tus-js-client(resumable upload)を使う
Resumable upload(大容量)
Storage はtus.io プロトコルに対応。中断しても再開できる。50MB 以上はこちら推奨。
import * as tus from "tus-js-client"
const upload = new tus.Upload(file, {
endpoint: `${URL}/storage/v1/upload/resumable`,
retryDelays: [0, 3000, 5000, 10000],
headers: {
authorization: `Bearer ${token}`,
"x-upsert": "true",
},
metadata: {
bucketName: "videos",
objectName: `${userId}/video.mp4`,
contentType: file.type,
cacheControl: "3600",
},
chunkSize: 6 * 1024 * 1024,
onError: console.error,
onProgress: (bytesUploaded, bytesTotal) => {
const pct = ((bytesUploaded / bytesTotal) * 100).toFixed(1)
console.log(`${pct}%`)
},
onSuccess: () => console.log("done"),
})
upload.start()
ダウンロード / URL 取得
Public bucket
const { data } = supabase.storage
.from("avatars")
.getPublicUrl(`${userId}/profile.png`)
console.log(data.publicUrl)
// https://xxxx.supabase.co/storage/v1/object/public/avatars/uuid/profile.png
Private bucket(署名付き URL)
const { data, error } = await supabase.storage
.from("documents")
.createSignedUrl(`${userId}/contract.pdf`, 3600) // 1 時間有効
console.log(data.signedUrl)
署名付き URL(書き込み用)
// クライアントに直接アップロードさせるが、サーバ側でパスを発行
const { data } = await supabase.storage
.from("uploads")
.createSignedUploadUrl(`${userId}/${filename}`)
// data.signedUrl, data.token, data.path
// クライアントでこの URL に PUT
Blob としてダウンロード
const { data, error } = await supabase.storage
.from("documents")
.download(`${userId}/contract.pdf`)
const url = URL.createObjectURL(data)
一覧取得
const { data } = await supabase.storage
.from("avatars")
.list(userId, {
limit: 100,
offset: 0,
sortBy: { column: "created_at", order: "desc" },
})
削除 / 移動 / コピー
// 削除
await supabase.storage.from("avatars").remove([
`${userId}/profile.png`,
`${userId}/old.png`,
])
// 移動
await supabase.storage.from("avatars").move(
`${userId}/old.png`,
`${userId}/archive/old.png`,
)
// コピー
await supabase.storage.from("avatars").copy(
`${userId}/profile.png`,
`${userId}/profile-backup.png`,
)
RLS(重要)
Storage の権限はstorage.objects テーブルに対する RLSで表現する。
例: 「自分のフォルダだけ読み書きできる」
-- storage.objects.name は "userId/filename.png" の形式
create policy "Users can manage their own folder"
on storage.objects
for all
to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = auth.uid()::text
)
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
例: 公開バケットだが認証ユーザだけ書ける
create policy "Public read"
on storage.objects for select
using (bucket_id = 'avatars');
create policy "Authenticated upload"
on storage.objects for insert
to authenticated
with check (bucket_id = 'avatars');
画像変換(Image Transformations)
Pro 以上でサーバ側で画像を変換できる。クエリ文字列で指定。
const { data } = supabase.storage
.from("avatars")
.getPublicUrl(`${userId}/profile.png`, {
transform: {
width: 200,
height: 200,
resize: "cover", // "cover" | "contain" | "fill"
quality: 80,
format: "origin", // 自動で WebP / AVIF
},
})
署名付き URL でも
await supabase.storage
.from("private-images")
.createSignedUrl("path", 3600, {
transform: { width: 800, quality: 75 },
})
キャッシュと CDN
- Storage はCDN(Cloudflare)を経由
cacheControlで TTL 制御- 署名付き URL は毎回ユニークなため CDN 効果が薄い → 公開 URL を使う方が速い
- 変換後の画像も CDN キャッシュされる
SPA でのアバター更新パターン
async function uploadAvatar(file: File) {
const userId = (await supabase.auth.getUser()).data.user!.id
const path = `${userId}/avatar-${Date.now()}.png`
const { error } = await supabase.storage
.from("avatars")
.upload(path, file, { upsert: true })
if (error) throw error
// profiles テーブルに URL を記録
const { data } = supabase.storage.from("avatars").getPublicUrl(path)
await supabase.from("profiles").update({ avatar_url: data.publicUrl })
.eq("id", userId)
}
容量と料金
- Free: 1 GB ストレージ / 2 GB 転送
- Pro: 100 GB ストレージ / 200 GB 転送(追加は従量)
- 1 ファイル最大 50MB(クラウドのデフォルト、増やせる)
- 動画など大容量は外部(Cloudflare Stream / Mux)も検討
S3 互換 API
Storage はS3 互換のエンドポイントを持つ。aws-sdk / s3cmd / rclone がそのまま使える。
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"
const s3 = new S3Client({
region: "ap-northeast-1",
endpoint: `${URL}/storage/v1/s3`,
credentials: { accessKeyId: ID, secretAccessKey: KEY }, // Storage の S3 access key
forcePathStyle: true,
})
await s3.send(new PutObjectCommand({
Bucket: "avatars",
Key: "path/to/file.png",
Body: buffer,
}))
失敗パターン
| 症状 | 原因 |
|---|---|
| 403 / RLS violation | storage.objects の RLS ポリシー不足 |
| 「The resource was not found」 | バケットが無い / パス間違い |
| 大ファイルで失敗 | tus.io(resumable)に切替 |
| Public URL なのに 404 | バケットを Public にし忘れ / RLS 干渉 |
| 画像変換が効かない | Pro 以上 / 元画像が透明 PNG なら format 注意 |
| cache が効かない | cacheControl 設定 / クエリ毎回ユニーク |
セキュリティ Tips
- 個人ファイルはPrivate + ユーザ別フォルダが基本
- upsert: true は他ユーザのファイルを上書きできてしまわないか RLS で制御
- MIME / サイズ制限をサーバ側で必ず設定(クライアント任せにしない)
- EXIF を抜いたり、画像最適化をサーバで行う場合はEdge Function 経由
フォルダ命名のおすすめ
{userId}/{type}/{timestamp}-{filename} の形が安全。
RLS で「先頭フォルダ = auth.uid()」とすれば自動的に他ユーザを締め出せる。