EAZIP BLOG
5 Ways to Download Supabase Storage Files as a ZIP
Supabase Storage has no bulk ZIP download in the dashboard or API. The five approaches that work — a container job, a pre-built or streamed Edge Function ZIP, browser-side zipping with createSignedUrls, and a managed ZIP API — sized and costed against the unified egress quota.
Supabase Storage serves one object per request. There is no "download
folder" in the dashboard and no storage-js or REST endpoint that returns
a prefix as an archive, so every "download all as ZIP" button on a Supabase
app was built by its developers. Two Supabase specifics decide which build
is right: createSignedUrls signs a whole array of paths in one call, and
Storage shares one project egress quota with your database and API — so how
often a method re-fetches the files shows up on the bill.
Which method should you use?
| Method | Practical archive size | What it costs you | Best for |
|---|---|---|---|
| Container job | Up to the container's disk — 200 GB on Fargate | Container runtime, plus ~$0.09/GB to push the archive back from AWS | Scheduled bundles |
| Edge Function, pre-built | Up to the 5 GB standard-upload limit, if it copies inside 400 s | A second stored copy to serve and clean up | An archive the user can wait for |
| Edge Function, streamed | Whatever the user can download inside the same 400 s | Nothing stored — but slow clients get a truncated ZIP | Small on-demand archives |
| Browser ZIP | Hundreds of MB routine; GBs is where tab memory bites | No server and no account — egress on every download | A "download all" button |
| Managed ZIP API | 5 GB per job on the free tier, up to 500 GB on the largest plan | Metered volume past the free tier | Recurring multi-GB exports |
One thing the table can't show: methods 1 to 4 all draw egress on every download. Building the archive ahead of time moves when the work happens, not what each download costs — only method 5 takes repeat downloads off Supabase.
1. Mirror and zip in a container
The batch pattern that needs no ZIP code: a container (AWS Fargate,
Cloudflare Containers, any job runner) mirrors the prefix to disk, zips it,
and uploads the archive back into Storage, where your app hands it out as an
ordinary signed URL. Supabase Storage speaks the S3 protocol, so rclone
drives both legs against
https://<project-ref>.storage.supabase.co/storage/v1/s3:
rclone copy supabase:your-bucket/exports ./export
zip -r export.zip ./export
rclone copyto export.zip supabase:your-bucket/archives/export.zipThe S3 access keys this needs bypass Row Level Security across every bucket, so they stay on the job runner and never reach a browser.
Where it stops working:
- The return leg is the cost nobody budgets. Pulling the objects out draws Supabase egress once per run, and pushing the archive back bills wherever the container runs — from AWS Fargate that's data transfer out at ~$0.09/GB, so a 50 GB export costs ~$4.50 every run before Supabase's own meter moves.
- The export has to fit on disk twice, briefly — mirror plus archive. Fargate ephemeral storage stretches to 200 GB; Cloudflare Containers offer a few GB.
- It isn't on-demand, and the archive is a second copy. Nobody clicking "download all" waits for a cold container to mirror 20 GB, and the file you built is stale as soon as an object changes.
2. Pre-build the ZIP in an Edge Function
Keep it inside Supabase: a function reads the objects with the service role key, streams the archive back into a private bucket, and returns a signed URL. The container pattern without the container — and without the return-leg bill, because the bytes never leave the platform.
import { createClient } from 'jsr:@supabase/supabase-js@2';
import { ZipWriter } from 'jsr:@zip-js/zip-js';
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
);
Deno.serve(async (req) => {
const { bucket, prefix } = await req.json();
const { data: objects } = await supabase.storage.from(bucket).list(prefix);
const files = objects!.filter((o) => o.id); // folders come back with a null id
const { readable, writable } = new TransformStream();
// level: 0 stores instead of deflating. Edge Functions get 2 s of CPU per
// request, and JPEG/PNG/MP4/PDF don't shrink anyway.
const zip = new ZipWriter(writable, { level: 0 });
const build = (async () => {
for (const object of files) {
const { data } = await supabase.storage
.from(bucket)
.download(`${prefix}/${object.name}`);
await zip.add(object.name, data!.stream());
}
await zip.close();
})();
// storage-js sends a ReadableStream body with duplex: 'half', so the
// archive uploads while it is still being built and never sits in memory.
const key = `archives/${crypto.randomUUID()}.zip`;
await supabase.storage
.from(bucket)
.upload(key, readable, { contentType: 'application/zip' });
await build;
const { data: signed } = await supabase.storage
.from(bucket)
.createSignedUrl(key, 3600);
return Response.json({ url: signed!.signedUrl });
});Because both legs stream, 256 MB of memory is not the ceiling — the 400 s wall clock is, and it is spent on Supabase-to-Supabase transfer rather than on your user's connection.
Where it stops working: a plain upload() tops out at 5 GB, and
Supabase recommends resumable (TUS) uploads above 6 MB for a reason — this
one has no resume, so a failure at minute six retries the whole archive.
You also inherit method 1's second stored copy and its cleanup — and that
copy draws egress every time somebody downloads it.
3. Stream the ZIP from an Edge Function
The same streaming build pointed at the response instead of a bucket, so the browser starts saving while objects are still being fetched. Nothing stored, nothing to clean up.
Deno.serve(async (req) => {
const { bucket, prefix } = await req.json();
const { data: objects } = await supabase.storage.from(bucket).list(prefix);
const files = objects!.filter((o) => o.id);
const { readable, writable } = new TransformStream();
const zip = new ZipWriter(writable, { level: 0 });
// Not awaited: the response has to start before the archive finishes.
(async () => {
for (const object of files) {
const { data } = await supabase.storage
.from(bucket)
.download(`${prefix}/${object.name}`);
await zip.add(object.name, data!.stream());
}
await zip.close();
})();
return new Response(readable, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="export.zip"',
},
});
});The same 400 s is now spent on the user's connection instead of Supabase's network. A 500 MB archive needs a sustained ~1.3 MB/s (≈10 Mbit/s) to land, and the Free plan allows 150 s. A user on hotel Wi-Fi doesn't get a slow download; they get a truncated ZIP.
Where it stops working: slow clients, well before size does. And with no
Content-Length there's no progress bar, and a drop at 95% restarts from
zero.
4. Zip in the browser with createSignedUrls
Your endpoint authorizes the user and signs their files in one call; the browser fetches and zips them locally with the open-source Eazip.js — no account, and no bytes through your server:
// Server: one call signs the whole folder
const { data } = await supabase.storage
.from('uploads')
.createSignedUrls(paths, 3600);
// Browser
import { createZip } from '@eazip/core';
const result = await createZip({
files: signed.map((s) => ({ url: s.signedUrl, filename: s.path })),
zipName: 'your-files.zip',
});
result.download();Supabase's signed URLs respond with permissive CORS, so this path usually needs no storage configuration at all, and nothing here runs on a 400 s budget — the tab holds the job, not a function. The full walkthrough — RLS-governed signing, pagination, the managed switch — is in Download Supabase Storage Files as a ZIP in the Browser.
Where it stops working: browser memory and tab lifetime bound archive size, and each download re-fetches every object — repeat downloads keep drawing the egress quota.
5. Use a managed ZIP API
The same signed URL list, submitted server-to-server as one job. Eazip fetches the files from Supabase once, builds the archive, and returns an expiring download link:
const { data } = await supabase.storage
.from('uploads')
.createSignedUrls(paths, 3600);
await fetch('https://api.eazip.io/jobs', {
method: 'POST',
headers: { 'X-API-Key': process.env.EAZIP_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
files: data.map((s) => ({ url: s.signedUrl, filename: s.path })),
zip_filename: 'your-files.zip',
expires_in: 172800,
}),
});This is the prepare-once pattern: your Supabase egress is drawn exactly once per export, and every repeat download is served from Eazip's zero-egress storage without touching Supabase again. The full walkthrough — service role vs. RLS, listing pagination, the quota math — is in Zip Supabase Storage Files into a Download Link via API.
Where it stops working — honestly: it's a vendor dependency with metered volume beyond the free tier (100 files, 5 GB output per job). A small app whose exports fit comfortably in method 4 doesn't need it.
Choosing in one sentence each
- A scheduled bundle, built once and downloaded by many: the container job (method 1).
- A small archive the user can wait a moment for: pre-build it in an Edge Function (method 2).
- A small archive on demand, with nothing stored: stream it from an Edge Function (method 3), if your users' connections are fast.
- A download-all button without infrastructure:
createSignedUrls+ browser ZIP (method 4). - Multi-GB, repeat downloads, or quota discipline: a managed ZIP API (method 5).
FAQ
Can the Supabase dashboard download a folder?
No — single files only. Bulk download is always something you build.
Can I just use the Supabase CLI?
For a one-off pull of a bucket you own, yes:
supabase storage cp -r ss:///your-bucket ./export --experimental
zip -r export.zip ./exportIt's experimental and needs a terminal and project credentials, so it will never be a button in your product — which is why it isn't one of the five methods above.
Can an Edge Function zip a whole bucket?
Not realistically — though memory is not the reason if you stream both legs. 2 s of CPU per request rules out compressing the archive, and the 400 s wall clock (150 s on Free) caps the whole request. Edge Functions fit bounded archives — one user's uploads, one order's assets — not a bucket.
Does zipping count against my Supabase egress quota?
Yes, whichever method: the bytes leave Supabase when they're fetched, and egress is one unified quota — 5 GB/month on Free, 250 GB included on Pro, then $0.09/GB uncached — shared by Database, Auth, Storage, Edge Functions and Realtime.
Methods 1 to 4 all draw about one archive's worth on every download — pre-building changes when the work happens, not what a download costs. Two things do move the number: method 1 pays an extra copy out per build, because the container sits outside Supabase, and a pre-built archive served from a stable path can hit the CDN at $0.03/GB, which an Edge Function response (method 3) and a freshly-signed URL per download (method 4) never do. Only method 5 takes repeat downloads off Supabase entirely.
Do I need the service role key?
For methods 1 to 3, yes — they read objects with credentials that bypass
Row Level Security, so those endpoints must authorize the request
themselves. Method 4 doesn't: a browser client signed in as the user can
call createSignedUrls under that user's RLS policies.
What about buckets with more than 100 files?
storage.list() paginates (100 rows by default) — loop the offset when
listing, whatever method you choose. Job size on the API path is
plan-bound, up to 20,000 files.