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