Skip to content

Supabase Storage large objects: multipart, ranges, conditionals, CDN and TUS

What Supabase Storage does with a 2 GiB object over the S3 endpoint, the REST read paths and TUS, which of the S3 headers an existing client sends are enforced and which are accepted and ignored, and where a self-hosted stack answers differently from a managed project. For anyone moving large objects, resumable uploads or an S3 client onto Storage. The claims under test come from the S3 compatibility page1, the resumable uploads page2, the file limits page3 and the Storage server source.

Every figure marked measured comes from the supabase-lab harness, experiments/storage-surface, run on 2026-10-12. One module, SS03, runs the SS03, SS04 and SS05 rows against one target per run; SS06 is the row-by-row diff of those runs and SS07 is a read of the Management API. Managed: one throwaway Pro-org project per run in ap-southeast-1, created and deleted by the module, Storage server 1.80.2, reached on two hosts, the gateway <ref>.supabase.co and the direct storage host <ref>.storage.supabase.co, with S3 in the session-token form and the service_role JWT4. Self-hosted: the self-hosted-defaults rig at its pinned supabase/supabase commit on Docker Desktop for macOS, one fresh stack per run, one host (the Envoy gateway at localhost:8000), S3 with the stack’s protocol keys. The client was the AWS SDK for JavaScript v3 and tus-js-client 4.3.1 under Node, from one laptop. Fixtures were generated from fixed seeds: big 2 GiB (128 parts of 16 MiB), mid 64 MiB, a gzip stream of 256 MiB of random bytes, and a 64x64 PNG.

Which run backs which figure. Managed figures are from run 2 (run-2026-10-12T08-47-30-825Z), except the cache-state and timing cells of SS03d and SS03k, which are from run 4 (run-2026-10-12T10-15-37-476Z), a re-run of those two rows with a fresh key per path and host. Run 3, made before run 4, repeats part of its rows and lost DNS near the end. Run 1 is superseded by a harness bug and is not cited. Each self-hosted variant ran the full battery once; sh-s3@v1.80.2 also ran SS03d and SS03k a second time as the control. Anything not marked measured is cited to the documentation as fetched on 2026-10-12 or to the Storage server source, and was not run.

TL;DR:

  • Multipart and resume work on every target. Per-part ETags equal the local part MD5 (128 of 128); a new process finds an unfinished upload with ListMultipartUploads and ListParts and completes it. The final ETag follows the backend: the AWS form -128 on managed and on self-hosted MinIO, the object MD5 on the self-hosted file backend.
  • Single ranges return 206 with the right bytes everywhere. A multi-range request or a Bytes= unit with a capital B sent to S3 GetObject on managed returns 200 and the whole object, 2 GiB for big. Accept-Ranges is absent on every S3 response.
  • On managed, S3 GetObject enforces If-Match and If-Unmodified-Since (412). The CDN read paths answer from cache and ignore a wrong If-Match on a HIT. After an overwrite through S3, /object/authenticated served the pre-overwrite bytes from cache. S3 HeadObject ignored every conditional header on every target.
  • Accepted and ignored on every target: conditional PUTs (the object is overwritten), wrong checksums and a wrong Content-MD5 (the body is stored), and SSE-C, AES256 and aws:kms headers (an SSE-C object is readable without its key).
  • The CDN cached and served the 2 GiB object intact. In one probe per direction, /object/public and /object/authenticated share one cache entry per host on a public bucket; signed URLs have their own; the gateway and the direct host do not share. A cold 1 KiB range of the 2 GiB object answered with response headers within 1,718 ms (total 1,728 ms), managed run 4, one sample per cell; one repeat read took 31,521 ms.
  • TUS: 5 and 8 MiB chunks completed with the right bytes (the docs say 6 MiB only), a resume after SIGKILL completed, also from the gateway to the direct host, and an existing key without x-upsert answered 409 (the docs say 400). Upload-Expires was 24 hours on managed, 1 hour on self-hosted MinIO and absent on the self-hosted file backend.
  • Self-hosted at the pinned Storage 1.74.0 ignores If-Match and drops Content-Encoding: gzip; with the image at 1.80.2, S3 GetObject and presigned GET enforce If-Match and If-Unmodified-Since as managed does (412), and the REST read paths differ, because managed answers 200 or 206 from cache. Content-Encoding: gzip matches managed on every path. Envoy echoes the Origin and exposes *; managed sends Access-Control-Allow-Origin: * and no expose list on GET.

Managed, ap-southeast-1Self-hosted rig (Docker Desktop, macOS)AWS SDK v3 /tus-js-client(Node, one laptop)gateway host<ref>.supabase.codirect host<ref>.storage.supabase.coEnvoylocalhost:8000CDN(cf-cache-status)REST readsStorage 1.80.2S3 (DYNAMIC)REST readsS3 (DYNAMIC)Storage 1.74.0or 1.80.2file backend (named volume)or MinIO (bind mount)
  1. On managed the client reaches Storage through either host. REST reads (signed URL, /object/public, /object/authenticated) pass through the CDN and carry a cf-cache-status of MISS, HIT or REVALIDATED; S3 and presigned S3 reads carry DYNAMIC, so the origin answers them.
  2. On self-hosted the client reaches Storage through Envoy on one host, with no cache. The backend is the file backend on a named Docker volume or MinIO on a bind mount.

You needUseWhy (measured unless marked)
To upload a 2 GiB object with resumeS3 multipart with 16 MiB parts, or TUSBoth resumed from a new process on every target; S3 via ListMultipartUploads + ListParts, TUS from the stored upload URL
To check an upload landed intactA SHA-256 of a GET, or per-part ETags against local part MD5sWrong x-amz-checksum-* and Content-MD5 values were accepted and the body stored
A ranged read that must reflect the current objectS3 GetObject or a presigned S3 GETDYNAMIC on managed, so the origin answers; it enforced If-Match at 1.80.2
A cacheable ranged read/object/public or /object/authenticatedMISS then HIT; the two share one cache entry per host on a public bucket
To replace an object that readers fetch through the CDNA new key/object/authenticated served the pre-overwrite bytes from cache after an S3 overwrite; the resumable uploads page advises new paths too2
”Create only if absent”A check in your own code, or TUS without x-upsertPutObject If-None-Match: * overwrote on every target; TUS without x-upsert answered 409
Data encrypted under a key you holdClient-side encryptionSSE-C headers were accepted and ignored; a client-side AES-256-GCM object round-tripped by chunk-aligned ranges
A browser multipart uploadS3 UploadPart from the pageManaged exposes etag on UploadPart responses; GET responses expose no ETag or Content-Range
Self-hosted parity with managed on gzip and If-MatchThe Storage image at 1.80.21.74.0 drops Content-Encoding and ignores If-Match; at 1.80.2 Content-Encoding matches managed on every path and S3 GetObject enforces If-Match as managed does, while the REST read paths differ (managed answers 200 or 206 from cache)

Target labelStorage serverBackendArtifact
managed1.80.2not observablerun 2 08-47-30; run 4 10-15-37 for SS03d and SS03k cache state
sh-file@v1.74.01.74.0 (the pinned compose file)file, named volume08-46-52
sh-s3@v1.74.01.74.0MinIO09-03-10
sh-file@v1.80.21.80.2 (image override)file, named volume08-57-25
sh-s3@v1.80.21.80.2 (image override)MinIO08-52-50; control for run 4 09-36-10

The label is sh-<STORAGE_BACKEND>@<storage image tag>, read from the running container. Running 1.80.2 locally separates a version effect from a backend effect.

The default upload limit was 52428800 bytes on both sides: default_file_size_limit in the managed project’s storage config, and FILE_SIZE_LIMIT in the self-hosted container. The file limits page caps the global limit at 50 MB on Free and 500 GB on Pro.3 The module records the refusal at the default first, then raises the limit to 10737418240 bytes: on managed a PATCH answered 200 and the first 64 MiB PutObject to succeed after the PATCH finished 8 s after the raise (run 2; 10 s in run 4, 12 s in run 3); on self-hosted the first PutObject succeeded within 1 s.

A 64 MiB upload at the default limit (managed run 2, self-hosted sh-s3@v1.80.2):

RequestManagedSelf-hosted
S3 PutObject413, text/plain, EntityTooLarge413, application/xml, EntityTooLarge; sh-file@v1.74.0 got 503 text/plain “upstream connect error or disconnect/reset before headers” from Envoy
REST POST400, application/json, EntityTooLarge, body statusCode 413same
Multipart, four 16 MiB partspart 4 refused, 413 EntityTooLarge, once the running total passed the limitsame, except sh-file@v1.74.0 (part 4 503)

One uncaptured development run of sh-file@v1.74.0 (operator note, no artifact) answered the same 64 MiB PutObject with 413 EntityTooLarge, so that cell is not stable.


Row (SS03a, SS03b)ManagedSelf-hosted, all four
Per-part ETag equals the local part MD5128/128128/128
ListParts after 40 parts200, 40 parts, 40 ETags matchsame
Client process exits after 40 parts; a new processListMultipartUploads finds the upload, ListParts returns 40, 88 more parts uploaded, CompleteMultipartUpload 200same
SHA-256 of a full GET equals the fixturetrue, 2147483648 bytestrue
Final ETagAWS multipart form, MD5 of the part MD5s, -128sh-s3@* the same; sh-file@* the object MD5
AbortMultipartUploadListParts after it 404 NoSuchUpload; the upload is gone from ListMultipartUploadssame

The ETag form follows the backend and not the version. The same split shows on single uploads: one 64 MiB PutObject with a known length got "b5d87668321e5b89a5adfed51e705c60-4" on managed and sh-s3, and the object MD5 on sh-file. An unknown-length PutObject (chunked transfer encoding) got the -4 form on managed and sh-s3. The compatibility page makes no statement about ETag form.

SS03j, information only, one sample each from one laptop: the 2 GiB multipart upload to the direct host took 208.3 s and the download from the gateway 58.1 s (managed run 2). That is not a throughput figure.


Single ranges (the first 1 KiB, a 4 KiB middle range, suffix -1024, open N-) returned 206 with the right Content-Range and bytes on mid and big, on S3 and REST, on every target (SS03c). The rows that differ:

RequestManagedsh-file 1.74.0 / 1.80.2sh-s3 1.74.0 / 1.80.2
Accept-Ranges on S3 HeadObject and GetObjectabsentabsentabsent
Accept-Ranges on a REST 206absentbytesbytes
Past EOF, S3416 InvalidRange416 InvalidRange416 S3Error / 416 InvalidRange
Past EOF, REST416, Content-Range: bytes */67108864, empty body416 JSON500 JSON / 416 JSON
bytes=0-9,20-29, S3200, whole object416 InvalidRange200, whole object
bytes=0-9,20-29, REST206, multipart/byteranges416200, whole object
Bytes=0-9 (capital unit), S3200, whole object416 / 206 bytes 0-9200, whole object
Bytes=0-9, REST206 bytes 0-9416 / 206 bytes 0-9200, whole object

On managed the REST answer to a multi-range or a capital-unit request differs from the S3 answer on the same object, and the S3 answer matches sh-s3. The multipart/byteranges body and the case-insensitive unit on managed REST therefore come from the layer in front of the origin; that is inference from the pattern, since the CDN serves REST reads and not S3 reads. A client that sends a multi-range request to S3 GetObject on managed or sh-s3 receives the whole object with status 200, which for big is 2 GiB.

The compatibility page lists Range and PartNumber as supported on GetObject.1 Range holds for single ranges. PartNumber is in the checksums section below.


SS03d reads one 1 KiB range at offset 1 MiB through every read path and host; SS03k reads whole objects. The first design read keys that earlier rows had already warmed, so run 4 gives every path and host its own server-side copy (CopyObject to a fresh key, deleted after its two reads) and the first read is the first request for that key anywhere. Every read returned 206 with the right bytes on every target and host. Managed cache state, run 4, mid and big alike:

PathFirst read, fresh keyRepeat read
S3 presigned GETDYNAMICDYNAMIC
Signed URLMISSREVALIDATED
/object/publicMISSHIT
/object/authenticatedMISSHIT, except the direct-host repeat on big, MISS

Time for the 1 KiB range of big, time to response headers and total, milliseconds, managed run 4:

PathGateway, coldGateway, repeatDirect, coldDirect, repeat
Presigned185 / 186160 / 160197 / 197158 / 159
Signed URL272 / 283111 / 111282 / 312109 / 110
Public389 / 39587 / 87319 / 33086 / 86
Authenticated1718 / 1728102 / 102337 / 36331521 / 31527 (MISS)

A cold ranged read of the 2 GiB object answered within 1,718 ms to headers on every path, while whole 2 GiB reads in SS03k took 53,064 to 155,110 ms from this vantage, so for a 1 KiB range on a MISS the headers arrived before a whole-object fetch could have finished (inferred; mechanism not measured). One repeat read, /object/authenticated on the direct host, was a MISS that took 31,521 ms for 1 KiB; in run 3 the same cell was a HIT at 116 ms. A request queued behind the edge filling the whole object after the first MISS would fit that wait. It is one sample and the mechanism was not measured. The self-hosted control has no cache: every cold and repeat timing was 4 to 105 ms locally.

Fresh key per probe, mid, a public bucket, managed:

First read, thenRun 4Run 3
Authenticated, then public, same hostMISS -> public on the gateway 206 HITnot measured (copy timed out)
Public, then authenticated, same hostMISS -> authenticated on the gateway 206 HITsame
Authenticated, then signed URL, same hostMISS -> signed URL on the gateway 206 MISSnot measured
Authenticated on the gateway, then on the direct hostMISS -> authenticated on the direct host 206 MISSsame
Public on the gateway, then on the direct hostMISS -> public on the direct host 206 MISSsame

On a public bucket, in one probe per direction, /object/public and /object/authenticated share one cache entry per host. Signed URLs have entries of their own (one direction probed), and the gateway and the direct storage host do not share. The same probes on a private bucket were not run in this lab; the data residency page records a separate, earlier measurement of private-object caching across users. The S3 SDK cells do not record the cache header; the presigned S3 GET shows DYNAMIC.


SS03e, a 1 MiB object. The managed CDN columns are the CDN’s answers for a cached object: when these were sent, every conditional GET after the control read on the signed, public and authenticated paths showed HIT, and every HEAD on them REVALIDATED. S3 and presigned reads are DYNAMIC, the origin’s answer. The cold-cache case of a conditional read was not measured.

RequestManaged S3 / presignedManaged signed, public, authenticatedSelf-hosted 1.74.0 (file, s3)Self-hosted 1.80.2 (file, s3)
GET If-None-Match = ETag304304 HIT304304
GET If-Match wrong412 PreconditionFailed200 HIT200412 PreconditionFailed
GET If-Modified-Since = Last-Modified304304 HITfile 200, s3 304304
GET If-Unmodified-Since yesterday412 PreconditionFailed200 HIT200412 PreconditionFailed
GET other ETag + If-Modified-Since = Last-Modified200200file 200, s3 304file 200, s3 304
S3 HEAD, any of the above200-200200
HEAD on the signed URL-304 for If-None-Match and If-Modified-Since, 200 for a wrong If-Match and If-Unmodified-Since, REVALIDATEDIf-None-Match 304, If-Match and If-Unmodified-Since 200; If-Modified-Since file 200, s3 304as GET: 412 for a wrong If-Match and If-Unmodified-Since, 304 for If-None-Match and If-Modified-Since
HEAD on public and authenticated-as the signed URL, REVALIDATED200 for every condition200 for every condition

S3 HeadObject ignored every conditional header on every target; the compatibility page lists all four as supported on HeadObject.1 RFC 9110 has a recipient ignore If-Modified-Since when If-None-Match is present5, so “other ETag + If-Modified-Since” should be 200. Managed and the file backend answer 200; the sh-s3 targets answer 304. The Storage server passes conditional reads to its S3 backend, so the sh-s3 answers here (and If-Modified-Since 304 at 1.74.0) may be MinIO’s rather than the Storage server’s. Read those cells as “self-hosted with MinIO”. Managed answers 200; its backend is not observable here, though its -128 multipart ETag form matches sh-s3.

Ranged reads with a wrong If-Match, after two warming reads of the same range:

PathManaged (run 2)Self-hosted 1.74.0Self-hosted 1.80.2
Presigned S3412, DYNAMIC206412
Signed URL, public, authenticated206 HIT, 1024 bytes206412

The module read cond-overwrite.bin twice through /object/authenticated with a range, overwrote it through S3, then asked for the same range with If-Match set to the first version’s ETag:

PathManagedSelf-hosted 1.74.0Self-hosted 1.80.2
/object/authenticated206 HIT, 100 bytes, first-version bytes206, second-version bytes412
S3 GetObject412 PreconditionFailed206, 100 bytes, second-version bytes412 PreconditionFailed
/object/public206 MISS, 100 bytes, second-version bytes206, second-version bytes412

On managed, /object/authenticated served the pre-overwrite bytes from the cache (inferred: the If-Match matched the cached copy), and the public URL, on a MISS, returned the new bytes with status 206 although they no longer matched the If-Match ETag. The Storage server at 1.80.2 enforces If-Match on every path on self-hosted, which has no cache; on managed only the S3 paths, which the CDN does not cache, give the origin’s answer. The resumable uploads page warns that an overwrite takes time to propagate to the edge and recommends a new path.2 Not measured: how long the stale copy stays served, and whether a different Range or a fresh signed URL avoids it. The image transformations page records stale variants after an overwrite on the render path.


Identical on every target (SS03f, no key differs in SS06):

Request on an existing keyMeasured
PutObject If-None-Match: *200, overwritten
PutObject If-Match with a stale ETag200, overwritten
CompleteMultipartUpload If-None-Match: *200, overwritten
CreateMultipartUpload If-None-Match: *200, upload id returned
UploadPartCopy x-amz-copy-source-if-match wrong200, ETag returned

The compatibility page lists no conditional headers on PutObject, marks the x-amz-copy-source-if-* conditions supported on CopyObject and unsupported on UploadPartCopy.1 Measured: the write conditions are accepted and ignored, so an “only create if absent” or “only replace this version” PUT overwrites. CopyObject’s copy-source conditions were not tested.


Identical on every target (SS03g; in SS06 only the unknown-length PutObject ETag differs):

RequestMeasured
PutObject x-amz-checksum-sha256 correct / wrong200, stored, both; no checksum echoed
PutObject Content-MD5 correct / wrong200, stored, both
PutObject x-amz-checksum-crc32 wrong (header)200, stored
aws-chunked body with a trailing x-amz-checksum-crc32, correct / wrong200, stored, both
SDK default (x-amz-checksum-crc32 as a header for a buffer body) and requestChecksumCalculation: WHEN_REQUIRED200, both
HeadObject / GetObject ChecksumMode: ENABLED200, no SHA-256 or CRC32 returned
GetObjectAttributes200, application/octet-stream, 262144 bytes of object body: the request fell through to GetObject
UploadPart with SHA-256 correct / wrong200, both
HEAD ?partNumber=1 on the 128-part object200, Content-Length 2147483648, the whole object and not part 1

The compatibility page marks Content-MD5 unsupported on PutObject and UploadPart and lists no checksum headers on PutObject.1 Measured, “unsupported” means accepted and not checked: a corrupted body sent with a correct client checksum would be stored. The page lists partNumber as supported on HeadObject; it returned the whole object’s length.


Identical on every target (SS03l; the one SS06 difference is the wording of a cell, 200 against 200 accepted (upload id)):

RequestMeasured
PutObject with SSE-C headers200, no SSE response headers; GET without the key 200 and the plaintext bytes
PutObject x-amz-server-side-encryption: AES256200; GET returns the plaintext; no SSE header on HEAD
PutObject aws:kms with a key id200, same
CreateMultipartUpload with each of the three200, an upload id
GetBucketEncryption200, a ListBucketResult body: routed to ListObjects

The compatibility page marks SSE unsupported and GetBucketEncryption unimplemented.1 Measured: the headers are accepted and ignored. A client that sets SSE-C treats the object as needing its key; the object is readable without it. Nothing here says how the platform encrypts data at rest, which the API does not expose.


ResponseManagedSelf-hosted (Envoy)
Preflight on the S3, signed, public and authenticated paths200, Access-Control-Allow-Origin: *, methods listed, requested headers echoed, max-age=3600, no expose list200, the Origin echoed (https://cors-probe.example), Access-Control-Expose-Headers: *
Actual GET on the same four pathsAccess-Control-Allow-Origin: *, no Access-Control-Expose-Headers: ETag, Content-Range and x-amz-checksum-* are not exposedAccess-Control-Expose-Headers: *
S3 UploadPart responseAccess-Control-Expose-Headers: etag*
PutBucketCors on a bucket name that does not exist200, and the bucket exists afterwards (deleted again, 204)same
GetBucketCors200, a ListBucketResult body: routed to ListObjectssame

A browser on managed can read the ETag of an UploadPart response, which a browser multipart upload needs to complete, and cannot read the ETag or Content-Range of a GET. The compatibility page marks PutBucketCors and GetBucketCors unimplemented;1 measured, PutBucketCors creates a bucket and GetBucketCors lists objects. A wildcard Access-Control-Expose-Headers counts as a wildcard only for requests without credentials6, so the self-hosted * does not cover requests sent with cookies.


Managed run 2 (SS03i):

CaseS3REST
Missing key404, text/plain, NoSuchKey400, application/json, NoSuchKey, body statusCode 404
Missing bucket404, text/plain, NoSuchKey400, NoSuchBucket, body statusCode 404
Wrong secret / forged JWT403, SignatureDoesNotMatch400, AccessDenied, body statusCode 403
Key 100%.txt400, InvalidKey400, InvalidKey
Range past EOF416, InvalidRange416, empty body, no content type

S3 error bodies are XML served as text/plain on managed and as application/xml on every self-hosted target except the Envoy 503 text/plain at sh-file@v1.74.0. A missing bucket answers NoSuchKey on S3 everywhere. REST errors carry HTTP 400 with the real status in the JSON body. sh-s3@v1.74.0 answers a REST range past EOF with 500 InternalError and an S3 one with 416 S3Error; 1.80.2 gives 416 InvalidRange on both. The 413 and the Envoy 503 at the upload limit are in Targets and the upload limit. The S3 key page has the full InvalidKey matrix.


Whole reads of a gzip object and a 2 GiB object

Section titled “Whole reads of a gzip object and a 2 GiB object”

SS03k reads two objects whole through presigned S3, signed URL, /object/public and /object/authenticated on both hosts, first and repeat: the 256 MiB gzip stream, stored through S3 PutObject with ContentEncoding: gzip, and big. The requests sent Accept-Encoding: gzip, deflate, br, through raw node:http so nothing decompressed the body. Every read returned the stored bytes with the full length (Content-Length 268517089 for the gzip object, 2147483648 for big) and no Transfer-Encoding: 16 of 16 cells per object on managed in run 4, and 8 of 8 on every self-hosted target.

Cache state of the repeat whole read on managed, run 4:

Pathgzip object, gateway / directbig, gateway / direct
PresignedDYNAMIC / DYNAMICDYNAMIC / DYNAMIC
Signed URLMISS / MISSMISS / HIT
PublicHIT / HITHIT / HIT
AuthenticatedHIT / HITHIT / HIT

The edge cached and served the 2 GiB object intact. Signed URLs re-fetched the whole object on three of four repeat reads; their ranged repeats in SS03d were REVALIDATED. The targets differ only in the header on the gzip object:

TargetContent-Encoding on every read of the gzip object
Managed (1.80.2)gzip
sh-file@v1.80.2, sh-s3@v1.80.2gzip
sh-file@v1.74.0, sh-s3@v1.74.0absent

At 1.74.0 a gzip object is served as opaque bytes with no encoding header, and no Content-Encoding header is sent, so a client that decodes by header would not decode it (not measured); at 1.80.2 the header comes back. Neither the edge nor the origin re-encoded or truncated anything. A client that decodes (fetch, a browser) would see the decompressed body; that is a client property and was not measured.


Row (SS04)ManagedSelf-hosted
big, 6 MiB chunks, SHA-256 of a GET (SS04a)true (direct host)true
mid with 5, 6 and 8 MiB chunkscompleted, SHA-256 matches, all threesame
SIGKILL after 20 chunks, resume from the stored URL in a new process (SS04b)HEAD 200, Upload-Offset 125829120; completed with 322 more chunkssame
Killed on the gateway after 2 chunks, resumed on the direct hostHEAD 200, offset 12582912; completed, SHA-256 matchesn/a (one host)
The TUS object read through S3 (SS04c)range 206; ETag -342 formsh-s3@* -342 form; sh-file@* the object MD5
Upload-Expires on the creation response (SS04d)present, 24 hours aheadsh-s3@* present, 1 hour; sh-file@* absent
TUS to an existing key without x-upsertrefused, 409 “The resource already exists”same

The ETag of a TUS object counts the backend’s parts, 342 for the 2 GiB object (one per 6 MiB TUS chunk), so it does not match the ETag of a multipart upload of the same bytes. The resumable uploads page recommends the direct storage host for large uploads;2 the 2 GiB TUS upload used it, and the cross-host row shows an upload URL created on the gateway resuming on the direct host.


SS05 uploads mid encrypted client-side as AES-256-GCM in 4 MiB chunks (each with its 16-byte tag), as application/octet-stream. The key never left the test process. Same result on every target:

RowMeasured
Chunk-aligned ranges for chunks 0, 7 and 15 via S3 and REST (SS05a)each decrypts to the matching plaintext slice
storage.objects.metadata (SS05b)keys cacheControl, contentLength, eTag, httpStatusCode, lastModified, mimetype, size; user_metadata {}; cacheControl no-cache
Image render on the ciphertext (SS05c)managed 400 “The source image file is too large to process”; self-hosted 400 “The source image is invalid or unsupported for rendering”; the PNG control 200 image/png everywhere
Upload with no content type, REST and S3, ciphertext and the PNGstored mimetype application/octet-stream for all four: no sniffing
Owner-only RLS select policy (SS05d)before the policy every user got NoSuchKey (REST 400, S3 404); after it the owner got 206 on REST (managed MISS) and 206 on S3 with a user-JWT session token; the other user 400 (BYPASS) on REST and 404 NoSuchKey on S3; anon 400

The server records size, type, ETag and cache settings, and nothing beyond what the client sent. The metadata ETag is the -N form on managed and sh-s3, 32 hex characters on sh-file. SS05d read the owner first; the other user’s read right after it was not served from cache (BYPASS). That row used a 16-byte range and does not test a second user reading a cached private object whole.


From the SS06 diff (run-2026-10-12T09-28-56-937Z) of managed run 2 and the four self-hosted variants, the rows with a difference:

RowWhat differsFollows
SS03a, SS04c, SS05b, unknown-length PutObjectETag formbackend: managed = sh-s3
SS03cAccept-Ranges on REST, multi-range, capital unit, past EOFthe CDN on managed (inferred); backend and version on self-hosted (inferred)
SS03eIf-Match and If-Unmodified-Sinceversion (1.74.0 ignores them) and, on managed REST paths, the CDN (inferred)
SS03eIf-Modified-Since alone, and its precedencebackend (file 200, s3 304 at 1.74.0; inferred); the s3 cells may be MinIO’s answers
SS03eHEAD conditionals on public and authenticatedthe CDN (inferred; managed 304, self-hosted 200 for every condition)
SS03kContent-Encoding: gzip returned or droppedversion (absent at 1.74.0; inferred)
SS03hAccess-Control-Allow-Origin * against the Origin echoed; no expose list against *managed edge against Envoy gateway (inferred)
SS03iXML error content type; 500 on a REST range past EOFmanaged edge against Envoy gateway (inferred); sh-s3@v1.74.0
SS04dUpload-Expires 24 h / 1 h / absentdeployment configuration and backend (inferred)
SS05cimage render error text on the ciphertextimage service configuration (inferred)

Same on every target: SS03b, SS03d (statuses and bytes), SS03f, SS03g (checksums), SS03k (bytes and Content-Length), SS03l, SS04a, SS04b, SS05a, SS05d.

Where sh-s3 differs from both managed and sh-file (the If-Modified-Since precedence, a REST range past EOF at 1.74.0, whole-object answers to multi-range requests, the S3Error code), MinIO as the backing store is a candidate cause these runs do not separate from the Storage server’s own code. None of those rows is a statement about self-hosting in general.

Operator facts from the preflight run (run-2026-10-12T07-49-15-587Z) and every self-hosted run, on Docker Desktop for macOS:

FindingMeasuredChange
File backend on the default ./volumes/storage bind mounta 1 KiB upload: S3 500 InternalError; REST 500 “The file system does not support extended attributes or has the feature disabled.”the data directory on a named volume (storage-volume layer)
S3 ListBuckets at http://127.0.0.1:8000 against the STORAGE_PUBLIC_URL host (localhost:8000 on this rig, set from SUPABASE_PUBLIC_URL in the compose file)403 SignatureDoesNotMatch against 200address S3 at exactly that host
MinIO on the s3 override’s default named volume507 XMinioStorageFull (“minimum free drive threshold”) partway through a development run (operator note)MinIO data on a bind mount (minio-hostdir layer)
FILE_SIZE_LIMIT: 52428800 hard-coded in the pinned docker-compose.ymlan .env entry cannot raise itthe storage-limit layer

The file backend stores object metadata in extended attributes (the stack trace names fs-xattr), which the macOS bind mount refuses. The Storage server verifies SigV4 against the host of STORAGE_PUBLIC_URL when one is set (source read, the publicUrl branch7), so a client must use exactly that host. The layers are in experiments/self-hosted-defaults/compose and stack with make up LAYERS="..."; storage-image sets the Storage image tag.


Regions and the key-management API surface

Section titled “Regions and the key-management API surface”

SS07, read only, no project (run-2026-10-12T07-49-33-833Z):

ItemMeasured
GET /v1/projects/available-regions for the Pro org200; 17 specific regions (also 17 on 2026-08-20, when the codes were not recorded); smart groups americas, apac, emea; provider AWS
Specific region codesap-northeast-1, ap-northeast-2, ap-south-1, ap-southeast-1, ap-southeast-2, ca-central-1, eu-central-1, eu-central-2, eu-north-1, eu-west-1, eu-west-2, eu-west-3, sa-east-1, us-east-1, us-east-2, us-west-1, us-west-2
Operations in the published Management API OpenAPI document169
Operations mentioning encryption, KMS, customer-managed or bring-your-own keys1: PUT /v1/projects/{ref}/pgsodium
Operations with “key” in the path14: API keys and Auth signing keys only

No published Management API operation configures storage or database encryption keys. That is a statement about the API document. Whether a key option exists by another route (a contract, a support request) is a question the API cannot answer.


Where the documentation and the runtime stand

Section titled “Where the documentation and the runtime stand”
SourceClaimRuntime measuredStanding
Resumable uploads page2Chunk size “must be set to 6MB (for now) do not change it”5 and 8 MiB chunks completed with the right bytes on every target, n = 1 eachruntime more permissive
Resumable uploads pageAn existing path gives “400 Asset Already Exists”409 “The resource already exists” on every target. The same page’s concurrency section says a second upload to the same path gets 409 Conflictthe page disagrees with itself; runtime matches the 409
Resumable uploads pageThe upload URL is valid “for up to 24 hours”Upload-Expires 24 hours on managed, 1 hour on self-hosted MinIO, absent on the self-hosted file backendmanaged consistent; self-hosted differs
Compatibility page1HeadObject supports If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Sinceignored on every targetruntime ignores them
Compatibility pageGetObject supports the four conditional headersenforced by S3 GetObject at 1.80.2; ignored at 1.74.0 for If-Match and If-Unmodified-Since, and for If-Modified-Since on the file backend; the managed CDN paths answer from cacheholds on S3 at 1.80.2 only
Compatibility pageHeadObject supports partNumberHEAD ?partNumber=1 returned the whole object’s lengthruntime ignores it
Compatibility pageCopy-source conditions supported on CopyObject, unsupported on UploadPartCopyUploadPartCopy with a wrong x-amz-copy-source-if-match answered 200; CopyObject conditions not testedconsistent for UploadPartCopy, accepted rather than refused
Compatibility pageContent-MD5 and SSE unsupported; PutBucketCors, GetBucketCors and GetBucketEncryption unimplementedall accepted with 200; checksums and SSE ignored; PutBucketCors creates a bucket; the two GETs return a listing”unsupported” means accepted and ignored
Compatibility pageRange supported on GetObjectsingle ranges 206; a multi-range or capital-unit request returns the whole object with 200 on managed S3single ranges only

The RUNLOG records no upstream report for any row.


  • Managed is one full run (run 2) plus the SS03d and SS03k re-run (run 4); run 1 stopped part-way and is not cited. Each self-hosted variant is one full run, and sh-s3@v1.80.2 also has the SS03d and SS03k control. Most rows are statuses and headers, deterministic reads, but n = 1 cannot separate a flaky answer from a stable one; the sh-file@v1.74.0 413 (uncaptured operator note) against 503 (captured) at the upload limit is one that moved.
  • The timing cells are single samples from one laptop to ap-southeast-1. The 208.3 s upload and 58.1 s download of big, and the 53,064 to 155,110 ms whole reads, describe this vantage on this day.
  • The cache cells describe a public bucket, objects up to 2 GiB, and keys read seconds apart. Eviction, objects past 2 GiB and private buckets were not measured.
  • What generalises is the contract: which headers are enforced, which are ignored, and that the CDN answers REST reads from cache before the origin sees a precondition. The version split (1.74.0 against 1.80.2) is tied to the tags tested; a later Storage release can move any row.

PracticeEvidenceModule
Raise the global file size limit before a large upload.At the default 52428800: S3 413, REST 400 with body 413, multipart refused at part 4.SS03i
Verify uploads by per-part MD5s or a SHA-256 of a GET.Wrong x-amz-checksum-*, Content-MD5 and aws-chunked CRC32 values were stored; per-part ETag = part MD5, 128/128.SS03a, SS03g
Do not compare a final multipart ETag across backends.-128 on managed and sh-s3, the object MD5 on sh-file; a TUS upload of the same bytes gets -342.SS03a, SS04c
Resume multipart with ListMultipartUploads and ListParts.A new process found the upload, read 40 parts, uploaded 88 and completed; SHA-256 matched.SS03b
Send one range per request with a lowercase bytes= unit.Multi-range and Bytes=0-9 to S3 GetObject returned 200 and the whole object (2 GiB for big) on managed and sh-s3.SS03c
Do not gate range requests on Accept-Ranges.Absent on every S3 response and on managed REST 206s; single ranges still 206.SS03c
Read through S3 GetObject when a 412 must be enforced on managed.S3 412; signed, public and authenticated answered a wrong If-Match with 200/206 from cache.SS03e
Write a new key instead of overwriting an object served through the CDN.After an S3 overwrite /object/authenticated served first-version bytes 206 HIT. Duration not measured.SS03e
Enforce create-if-absent in your own code.PutObject If-None-Match: * and stale If-Match overwrote on every target.SS03f
Encrypt client-side when data must be unreadable without your key.SSE-C object readable without its key; AES-256-GCM chunks decrypted from ranged reads.SS03l, SS05a
Set the content type on upload.No sniffing: ciphertext and PNG without a type were stored as application/octet-stream.SS05c
In a browser, read ETags from UploadPart only.Managed exposes etag on UploadPart and no headers on GET; Envoy’s * does not cover credentialed requests.SS03h
Do not use PutBucketCors or GetBucketCors.PutBucketCors on a new name created a bucket; GetBucketCors returned a listing.SS03h
Match S3 errors on the XML code, REST errors on body statusCode.S3 XML is text/plain on managed, application/xml self-hosted; REST errors are HTTP 400.SS03i
Store the TUS upload URL so a crashed client can resume.Resume after SIGKILL at offset 125829120 completed; gateway to direct host also completed.SS04b
Send x-upsert: true on TUS overwrites; handle 409.An existing key without it answered 409 (the docs say 400).SS04d
Read Upload-Expires from the creation response.24 h managed, 1 h self-hosted MinIO, absent on the self-hosted file backend.SS04d
Pin self-hosted Storage at 1.80.2 when If-Match or gzip objects matter.1.74.0 ignored If-Match and dropped Content-Encoding: gzip; at 1.80.2 S3 GetObject and presigned GET enforced If-Match as managed does (the REST paths differ) and Content-Encoding: gzip matched managed on every path.SS06
Self-hosted: address S3 at exactly the STORAGE_PUBLIC_URL host.127.0.0.1:8000 got 403 SignatureDoesNotMatch where localhost:8000 got 200.rig, SS03 target row
Self-hosted on macOS: keep the file backend on a named volume.The default bind mount failed a 1 KiB upload with 500 (no extended attributes).rig, SS03 target row
Ask about customer-managed keys outside the Management API.169 operations; the only encryption match is PUT /v1/projects/{ref}/pgsodium.SS07b

Large object on StorageUploadReadS3 multipart(resume via ListParts;verify part MD5s)an S3 clientTUS(store the upload URL;x-upsert or 409)a browser ortus clientS3 GetObject / presigned(DYNAMIC; 412 enforced at 1.80.2)must reflect thecurrent objectpublic / authenticated(MISS then HIT; shared entry)cacheable,repeat readsnew key per versionobjects that change
  1. Upload from an S3 client: multipart in 16 MiB parts; resume with ListMultipartUploads and ListParts; check part ETags against local MD5s.
  2. Upload from a browser or a TUS client: store the upload URL for resume; send x-upsert: true to overwrite, otherwise expect 409.
  3. Read where the bytes must be current or a precondition must hold: S3 GetObject or a presigned S3 GET, which the CDN does not cache.
  4. Read for cacheable repeat traffic: /object/public or /object/authenticated, and give a changed object a new key, since the cache served pre-overwrite bytes.

  • Repeat runs on self-hosted (n = 1 per variant) and more than two full runs on managed.
  • Any region other than ap-southeast-1 and any vantage other than one laptop; SS03j and the SS03k timings are single samples, not throughput.
  • Dashboard-generated S3 access keys on managed (no API creates them).
  • Objects past 2 GiB; parallel part uploads; TUS with concurrent clients on one URL.
  • How long a stale cached copy is served after an overwrite, and whether a purge or a fresh signed URL changes it.
  • The SS03d warming probes on a private bucket.
  • Self-hosted on Linux amd64; any gateway other than Envoy; the RustFS override.
  • The self-hosted file backend on the default bind mount past the failed preflight.
  • Whether deleted objects free MinIO space (the MinIO data directory still held several GB after the module’s deletes; operator note).
  • CopyObject copy-source conditions.
  • Contract and certification questions (encryption at rest, compliance agreements, SLA).

From a checkout of supabase-lab with a personal access token for a Pro org, Docker for the self-hosted rows, and room for the fixtures (2 GiB, 256 MiB and 64 MiB) plus about 6 GB up and 40 GB down per managed run:

Terminal window
cd experiments/storage-surface
export PVLAB_ORG_PRO=<slug of a Pro org> # and SUPABASE_ACCESS_TOKEN in the environment
make probe MODS=SS03 # managed, about 45 min
make probe MODS=SS03 ROWS=SS03d,SS03k # the cache re-run
make -C ../self-hosted-defaults clean stack up LAYERS="storage-volume storage-limit" # sh-file
make probe-sh
make -C ../self-hosted-defaults clean stack up LAYERS="s3 minio-hostdir storage-limit storage-image" STORAGE_IMAGE_TAG=v1.80.2
make probe-sh
make ss06 RUNS=out/<date>/run-a.json,out/<date>/run-b.json
make probe MODS=SS07 # read only
make clean-fixtures
make sweep # lists leftover ss- projects

The module creates and deletes its own project. Results and caveats are in the RUNLOG, section “2026-10-12 - SS03 to SS07”.


ClaimHow it was checkedModule
Default limit 52428800, raised to 10737418240; 413 / 400 / Envoy 503 at the defaultManagement API storage config and container env; a 64 MiB PutObject, REST POST and four-part multipart at the defaultSS03 (SS03 target row, SS03i)
Part ETags, resume across processes, final ETag form, abort2 GiB in 128 parts; stop after 40, new process, ListMultipartUploads, ListParts, complete; SHA-256 of a GETSS03 (SS03a, SS03b)
Range matrixsingle, suffix, open, past-EOF, multi-range and capital-unit requests on S3 and REST, mid and bigSS03 (SS03c)
Cache state per path and host, warming, timingsfresh server-side copy per path and host, first and repeat reads, cf-cache-status, time to headers; run 4, with run 3 (made earlier) as a partial repeatSS03 (SS03d, SS03k), 10-15-37
Conditional reads and the overwrite caseGET and HEAD with the four headers on every path; ranged If-Match after warming; S3 overwrite then If-Match on the first ETagSS03 (SS03e)
Conditional writes ignoredPutObject, CompleteMultipartUpload, CreateMultipartUpload, UploadPartCopy with conditions on an existing keySS03 (SS03f)
Checksums not checked; GetObjectAttributes and HEAD partNumbercorrect and wrong checksums, Content-MD5, aws-chunked trailer, ChecksumMode, GetObjectAttributes, HEAD ?partNumber=1SS03 (SS03g)
CORSpreflight and actual requests from an arbitrary Origin on four paths; UploadPart; PutBucketCors, GetBucketCorsSS03 (SS03h)
Error bodiesmissing key and bucket, wrong secret, invalid key, range past EOF, on S3 and RESTSS03 (SS03i)
Whole gzip and 2 GiB readsraw node:http reads through every path and host, Content-Length, Content-Encoding, hashSS03 (SS03k)
SSE headers ignoredSSE-C, AES256, aws:kms on PutObject and CreateMultipartUpload; GET without the SSE-C key; GetBucketEncryptionSS03 (SS03l)
TUS chunk sizes, resume, ETag, Upload-Expires, 409tus-js-client 4.3.1; SIGKILL and a new process; cross-host resume; creation response; existing key without x-upsertSS03 (SS04a-d)
Client-side encrypted objectAES-256-GCM chunks, ranged decrypt, storage.objects.metadata, render, no-type upload, owner-only RLS on REST and S3SS03 (SS05a-d)
Managed against self-hostedrow-by-row diff of five runsSS06, 09-28-56
Self-hosted rig requirementspreflight 1 KiB upload on the default bind mount; ListBuckets on two hosts; the SigV4 source07-49-15; MinIO 507 is an operator note in the RUNLOG
17 regions; no key-management operationavailable-regions and the published OpenAPI documentSS07 (SS07a, SS07b)
The documentation claims in the disagreement tablethe compatibility, resumable uploads and file limits pages, fetched 2026-10-12documented

The artifacts, with refs and hostnames redacted, are in out/2026-10-12. Run 1 (run-2026-10-12T08-01-48-370Z) is kept there and superseded.


ModuleExperimentTestArtifact
SS03storage-surfacess03-large-objects.tsout/2026-10-12
SS04storage-surfacerow ids emitted by ss03-large-objects.tsout/2026-10-12
SS05storage-surfacerow ids emitted by ss03-large-objects.tsout/2026-10-12
SS06storage-surfacess06-selfhosted-diff.tsout/2026-10-12
SS07storage-surfacess07-regions-key-surface.tsout/2026-10-12

Ids with a letter suffix (SS03e, SS04b) are result rows inside the module; the facts files list them. The rig rows (“SS03 target row”) are the SS03_target result of the same module.

  1. Supabase, “S3 compatibility,” Supabase Docs (fetched 2026-10-12). https://supabase.com/docs/guides/storage/s3/compatibility ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8

  2. Supabase, “Resumable uploads,” Supabase Docs (fetched 2026-10-12). https://supabase.com/docs/guides/storage/uploads/resumable-uploads ↩ ↩2 ↩3 ↩4 ↩5

  3. Supabase, “Limits,” Supabase Docs, Storage (fetched 2026-10-12). https://supabase.com/docs/guides/storage/uploads/file-limits ↩ ↩2

  4. Supabase, “S3 authentication,” Supabase Docs. https://supabase.com/docs/guides/storage/s3/authentication ↩

  5. IETF, “RFC 9110: HTTP Semantics, section 13.1.3 If-Modified-Since,” RFC Editor. https://www.rfc-editor.org/rfc/rfc9110.html#name-if-modified-since ↩

  6. MDN, “Access-Control-Expose-Headers header,” MDN Web Docs. https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Expose-Headers ↩

  7. Supabase, “src/storage/protocols/s3/signature-v4.ts,” supabase/storage, GitHub, at tag v1.74.0. https://github.com/supabase/storage/blob/v1.74.0/src/storage/protocols/s3/signature-v4.ts ↩