EAZIP BLOG
4 Ways to Download an Entire S3 Bucket as a ZIP
S3 has no built-in way to download a bucket or prefix as a single ZIP. The four approaches that work: AWS CLI sync, a server-side streaming ZIP, browser-side zipping with presigned URLs, and a managed ZIP API — compared by scale, cost, and setup.
Amazon S3 stores and serves individual objects. There is no API call that returns a prefix as one archive, no "download folder" button in the console, and no way to hand someone a single link that resolves to many objects. Every "download the bucket as a ZIP" feature you have ever used was built on top of S3 — it is not something S3 does for you.
There are four ways to build it. They differ in who can run them, how much data they can move, and what they cost. Start with the table, then read the section that matches your situation.
Which method should you use?
| Method | Best for | Scale ceiling | Runs on |
|---|---|---|---|
| AWS CLI | You, one-off, bucket you own | Disk space | Your machine |
| Server-side streaming ZIP | A product feature, moderate sizes | Server timeout / egress budget | Your server or Lambda |
| Browser-side ZIP | A "download all" button, no extra infra | Browser memory and tab lifetime | Your user's browser |
| Managed ZIP API | Recurring exports, multi-GB jobs | Plan limits (up to 500 GB/job) | A ZIP service |
The honest short version: if the person downloading is you, use the CLI and stop reading. If you are shipping a feature to users, the real choice is between the last three, and it hinges on job size and how much infrastructure you want to own.
1. AWS CLI: sync, then zip locally
If you have credentials for the bucket and just need the files, mirror the prefix to disk and zip it:
aws s3 sync s3://your-bucket/exports/2026-08 ./export
zip -r export.zip ./exportaws s3 sync parallelizes downloads, resumes cleanly if you re-run it, and
supports --exclude/--include filters. For a one-time pull of any size this
is the right tool.
Where it stops working: it requires the AWS CLI, credentials, and enough local disk. You cannot ask a customer to do this, and it does not become a button in your product. If your task is "give end users a download-all feature", continue below.
2. Stream a ZIP from your own server
The classic product implementation: an endpoint lists the objects, streams
each one from S3, and pipes a ZIP to the response as it builds. In Node with
archiver:
import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3';
import archiver from 'archiver';
const s3 = new S3Client({ region: 'us-east-1' });
export async function zipPrefix(res, bucket, prefix) {
const archive = archiver('zip', { zlib: { level: 0 } });
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', 'attachment; filename="export.zip"');
archive.pipe(res);
let token;
do {
const page = await s3.send(
new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, ContinuationToken: token }),
);
for (const object of page.Contents ?? []) {
const item = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: object.Key }));
archive.append(item.Body, { name: object.Key.slice(prefix.length) });
}
token = page.NextContinuationToken;
} while (token);
await archive.finalize();
}Streaming keeps memory flat, so this works well for jobs in the hundreds of megabytes. It is a fine default when exports are small and occasional.
Where it stops working — with numbers:
- Egress is billed twice conceptually and once expensively. Every byte leaves AWS through your server at roughly $0.09/GB. A 20 GB export costs about $1.80 in bandwidth every time someone clicks the button.
- Timeouts. On Lambda the hard ceiling is 15 minutes; API Gateway cuts responses at 29 seconds unless you stream. Long-running connections through load balancers need idle-timeout tuning.
- No progress bar, no resume. Because the ZIP is generated on the fly,
the response has no
Content-Length. The user's browser cannot show a percentage or a time estimate for the download, Range requests do not work, and a connection dropped at 95% starts over from zero bytes.
3. Zip in the browser with presigned URLs
You can skip the server-side data path entirely: your endpoint returns short-lived presigned URLs, and the user's browser fetches the objects and builds the ZIP locally. The bytes flow S3 → browser, so your server never proxies the payload.
Server side, presign the objects (any runtime):
import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'us-east-1' });
export async function listSignedUrls(bucket, prefix) {
const { Contents = [] } = await s3.send(
new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix }),
);
return Promise.all(
Contents.map(async (object) => ({
url: await getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket, Key: object.Key }), {
expiresIn: 3600,
}),
filename: object.Key.slice(prefix.length),
})),
);
}Browser side, with the open-source Eazip.js (@eazip/core, MIT, no
account required):
import { createZip } from '@eazip/core';
const files = await fetch('/api/export-urls').then((r) => r.json());
const result = await createZip({ files, zipName: 'export.zip' });
result.download();JSZip and client-zip are alternatives for the browser step; the trade-off between them is mostly memory behavior and streaming support.
One S3-specific prerequisite: the bucket needs a
CORS policy
allowing GET from your app's origin, or the browser will refuse the
fetches.
The full walkthrough — prefix pagination, a Lambda variant of the endpoint, CORS details, and the one-line switch to a managed job — is in Download an S3 Bucket as a ZIP in the Browser.
Where it stops working: the browser is a constrained runtime. Memory limits make multi-gigabyte archives unreliable, a 1,000-file export means shipping 1,000 presigned URLs to the client before the job starts, and the job dies if the user closes or reloads the tab. For the input shapes and their limits, see Inputs and sources.
4. Use a managed ZIP API
The fourth option is to hand the fetch-and-zip work to a service. With Eazip, your server presigns the objects exactly as in method 3, then submits them as one job instead of returning them to the browser:
export async function POST() {
// listSignedUrls() is the same helper from method 3 —
// the same list, sent to a job instead of the browser.
const files = await listSignedUrls('your-bucket', 'reports/2026/');
const response = 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,
zip_filename: 'reports.zip',
expires_in: 172800,
}),
});
const { job_id } = await response.json();
return Response.json({ job_id });
}curl -X POST https://api.eazip.io/jobs \
-H "X-API-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"files": [
{ "url": "https://your-bucket.s3.amazonaws.com/reports/q1.pdf?X-Amz-...", "filename": "q1.pdf" },
{ "url": "https://your-bucket.s3.amazonaws.com/reports/q2.pdf?X-Amz-...", "filename": "q2.pdf" }
],
"zip_filename": "reports.zip",
"expires_in": 172800
}'The response is a job ID. Eazip fetches the URLs server-to-server — no bucket CORS configuration involved — and builds the archive, with ZIP64 and auto-splitting handled when you cap the per-archive size. When the job completes, polling or a webhook gives you an expiring download link to hand to the user. The job survives the user's tab, downloads support Range/resume, and per-job output scales to 500 GB on the largest plan. The full S3 walkthrough — presigning, egress costs, Glacier gotchas, delivery options — is in Zip S3 Files into a Download Link via API.
The cost shape is also different from method 2. Eazip adds no outbound-bandwidth fee to downloads, and in the default stored mode your bucket pays S3 egress once, when the archive is prepared. Repeat downloads are served from zero-egress storage — a user downloading the same 20 GB export five times costs you the S3 egress of method 2 exactly zero additional times.
Where it stops working — honestly: it is a vendor dependency, and while the free tier covers typical product use (100 files and 5 GB output per job), sustained heavy volume is metered. If your exports are small, infrequent, and already well inside one server's comfort zone, method 2 or 3 is enough and you should not add a service for it. The distinction that matters is architectural — whether the job must outlive a browser tab and who carries the bandwidth — not free versus paid.
Choosing in one sentence each
- It's for you, once:
aws s3 sync+zip(method 1). - Small, occasional exports in an existing app: stream from your server (method 2).
- A download-all button without new infrastructure: presigned URLs + a browser ZIP library (method 3).
- Multi-GB jobs, repeat downloads, or delivery that must not break: a managed ZIP API (method 4) — S3 egress paid once, no per-download cost after that.
Methods 3 and 4 also compose: start in the browser, and switch the same presigned-URL list to an Eazip job when jobs outgrow the tab. That decision path is written up in Choose your integration.
FAQ
Can S3 download a folder as a ZIP natively?
No. Neither the S3 API nor the AWS Console can archive a prefix. The console downloads one object at a time; the API serves bytes per object. Zipping is always an extra layer you (or a tool) build.
Can AWS Lambda zip an entire bucket?
Up to a point. The 15-minute execution cap is the hard bound; if you build
the archive on disk rather than streaming it, the 10 GB /tmp ceiling binds
first. And either way you pay S3-to-internet egress on every download. Past
tens of gigabytes, move the work somewhere without a wall clock.
Do I have to route the files through my own server?
No. Methods 3 and 4 both use presigned URLs so the payload never touches your server — the difference is whether the browser or a managed service does the fetching and zipping.
How long can presigned URLs live?
Up to 7 days with SigV4 (expiresIn: 604800). For ZIP jobs, shorter is
better: mint them right before the job starts and keep the window to minutes
or hours.
What about buckets with millions of objects?
No approach hands an end user a million-object ZIP in one click sensibly.
Filter to what the user actually needs (a prefix, a manifest, a date range),
and split output into multiple archives — auto-splitting by size is built
into managed jobs (max_zip_size_bytes).