Storage buckets and objects don't come with your Postgres dump

Recreate each bucket, copy objects through /storage/v1/object, and build public URLs from SUPABASE_URL at request time so no row needs rewriting.

Migration

· Chapter

8

·

2

min read

The answer. A dump of your project carries storage.objects metadata at best and the bytes never. The new project starts with zero buckets, so a migrated app whose database says "avatar at avatars/u1.png" is pointing at nothing. Do three things. Recreate every bucket with the same name and the same public/private flag. Copy the objects: fetch each from the old project (public URL, or a signed URL for private buckets) and POST /storage/v1/object/<bucket>/<path> on the new one with the service-role key, then verify status and byte size. And make sure rows store paths, not URLs; if URLs are assembled from SUPABASE_URL at request time, the cutover needs no row rewrite at all.

The pattern.

# 1. buckets: same name, same visibility
curl -X POST "$NEW_URL/storage/v1/bucket" -H "Authorization: Bearer $NEW_SERVICE_ROLE" \
  -H "Content-Type: application/json" -d '{"id":"my-bucket","name":"my-bucket","public":true}'

# 2. objects: list on the old side, one POST per object on the new side, verify the size
curl -s -X POST "$OLD_URL/storage/v1/object/list/my-bucket" -H "Authorization: Bearer $OLD_SERVICE_ROLE" \
  -H "Content-Type: application/json" -d '{"prefix":"","limit":1000}' | jq -r '.[].name' |
while read -r path; do
  curl -sf "$OLD_URL/storage/v1/object/public/my-bucket/$path" -o /tmp/obj
  curl -sf -X POST "$NEW_URL/storage/v1/object/my-bucket/$path" -H "Authorization: Bearer $NEW_SERVICE_ROLE" \
    -H "x-upsert: true" --data-binary @/tmp/obj
  [ "$(wc -c < /tmp/obj)" -eq "$(curl -sI "$NEW_URL/storage/v1/object/public/my-bucket/$path" | awk 'tolower($1)=="content-length:"{print $2+0}')" ] || echo "SIZE MISMATCH $path"
done
// 3. rows hold the path; the URL is derived at runtime, so nothing to rewrite
export const publicUrl = (bucket: string, path: string) =>
  `${env.SUPABASE_URL}/storage/v1/object/public/${bucket}/${path}`;

The receipt. In our product (HarperFlow), cutover day 2026-07-10: the database leg had been verified identical, the API and dashboard were already on the hosted project, and the hosted project had 0 storage buckets. Two public buckets were recreated by hand and the 3 objects that existed were copied by fetching each from the old instance's public URL and posting it to /storage/v1/object on the new one; all 3 then served 200 with the correct sizes. No database rows changed, because the app builds public URLs from SUPABASE_URL at runtime. Three files is a trivially small migration; the point is that the count was zero until someone checked, after the "DB migration done" message had already gone out.

Watch out.

  • Bucket settings (public flag, size limit, allowed MIME types) and storage RLS policies are configuration too. Recreate them; don't assume.
  • Fetching from a public URL only works for public buckets. For private ones, mint signed URLs on the old side or stream through the service role.
  • If you ever stored full URLs in rows, the cutover becomes a data migration. Fix that before you move, not during.

Related: hosted-cutover-verified-table-by-table · custom-access-token-hook-is-instance-config