Eazip

EAZIP BLOG

4 Ways to Download Cloudflare R2 Files as a ZIP

R2 has no built-in folder-to-ZIP download. The four approaches that work — rclone, zipping in a Worker or server, browser-side zipping with presigned URLs, and a managed ZIP API — compared with R2's zero-egress economics in mind.

Ryan··6 min readcloudflare-r2zip

Cloudflare R2 gives you S3-compatible object storage with one famous difference: no egress fees. What it doesn't give you — same as S3 — is any way to download a bucket or prefix as a single ZIP. No archive endpoint, no "download folder" in the dashboard.

The four ways to build it are the same shapes as on any object store, but R2's pricing changes which ones make sense: the classic cost objection to proxying files through a server (double egress) simply doesn't exist here, while a Cloudflare-native temptation — zipping inside a Worker — has a harder wall than most people expect.

Which method should you use?

MethodBest forScale ceilingRuns on
Mirror + zip in a containerScheduled bundles with stock toolsContainer disk; AWS egress if run on AWSFargate / Cloudflare Containers
Worker or server ZIPSmall archives, existing backendWorker: 128 MB memory · Server: timeoutsCloudflare Workers / your server
Browser-side ZIPA "download all" button, no infraBrowser memory, tab lifetimeYour user's browser
Managed ZIP APIRecurring, multi-GB exportsPlan limits (up to 500 GB/job)A ZIP service

1. Mirror and zip in a container with rclone

The general-purpose batch pattern: run a container (AWS Fargate, Cloudflare Containers, any job runner) that mirrors the prefix with rclone, zips it on local disk, and uploads the archive back to R2, where your app serves it as a normal object:

rclone copy r2:your-bucket/exports ./export
zip -r export.zip ./export
rclone copyto export.zip r2:your-bucket/archives/export.zip

Stock tools, no ZIP code, and the artifact lands back in the bucket — which is why this is the first design most teams sketch. (For a one-off pull of your own bucket, the same two commands on your laptop are still the right tool; the container version is what people build when it has to recur.)

Where it stops working:

  • Egress on the way back is the trap. Pulling out of R2 is free from anywhere — but the finished ZIP has to travel back, and that leg bills wherever the container runs. From AWS Fargate, the upload to R2 is AWS data transfer out at ~$0.09/GB — a 50 GB archive costs ~$4.50 every run. Cloudflare Containers meter their own egress too (unlike Workers and R2): $0.025/GB in North America and Europe past the included monthly allotment (1 TB; other regions get 500 GB at $0.04–0.05/GB). The zero-egress magic of R2 applies to R2 — not to the compute you bolt onto it.
  • The whole export must fit on the container's disk — twice, briefly (mirror + archive). Fargate ephemeral storage stretches to 200 GB; Cloudflare Containers instances currently offer a few GB, which rules out large archives entirely.
  • You now operate infrastructure for a download feature: scheduling, the container image, retries when run 47 dies mid-mirror.
  • The archive is a second copy — stored (and billed) alongside the originals, stale the moment files change, and in need of its own cleanup lifecycle.
  • It's not on-demand. A user clicking "download all" can't wait for a cold container to mirror 20 GB; this pattern fits schedules, not buttons.

2. Zip in a Worker or on your server

The Cloudflare-native instinct is a Worker with an R2 binding that streams objects into an archive. It works — within a boundary that arrives fast:

  • Workers cap at 128 MB of memory, and are billed on CPU time. A streaming ZIP keeps memory flat-ish, but compression is CPU, and a multi-gigabyte archive burns both. Practical Worker ZIPs are the small, bounded kind — a handful of documents, not a photo shoot.
  • A Node server doing the same (the archiver pattern) has no 128 MB wall, and — uniquely on R2 — proxying costs nothing in bandwidth, because R2 charges no egress. The remaining objections are operational, not financial: your server's time and connections, response timeouts, and no resume (Content-Length unknown, so no progress bar and a drop at 95% restarts from zero).

Where it stops working: size. Both hosts are fine for tens-to-hundreds of megabytes, and both degrade past that — the Worker on memory and CPU budget, the server on long-lived connections.

3. Zip in the browser with presigned URLs

Your endpoint lists the prefix and returns short-lived presigned URLs (the AWS SDK presigns against {account}.r2.cloudflarestorage.com), and the user's browser fetches the objects and builds the ZIP locally with the open-source Eazip.js — no account, and your server never touches the bytes:

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();

The bucket needs a CORS rule allowing GET from your app's origin (configured per bucket in the Cloudflare dashboard). Public buckets on r2.dev or a custom domain can skip presigning entirely.

Where it stops working: the browser's memory and the tab's lifetime — multi-gigabyte archives and reload-surviving downloads are not browser territory, and a 1,000-object export means shipping 1,000 presigned URLs to the client first.

4. Use a managed ZIP API

Same presigned URLs, submitted server-to-server as one job instead of handed to a browser. Eazip fetches the objects from R2, builds the archive (ZIP64, auto-split past any size cap you set), and returns an expiring download link with Range/resume:

curl -X POST https://api.eazip.io/jobs \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "files": [
      { "url": "https://<account>.r2.cloudflarestorage.com/bucket/a.jpg?X-Amz-...", "filename": "a.jpg" }
    ],
    "zip_filename": "export.zip",
    "expires_in": 172800
  }'

On R2 this pipeline has a property no other storage matches: zero bandwidth cost end to end. R2 charges nothing for Eazip's reads, and Eazip adds no outbound-bandwidth fee — a 100 GB export and its five repeat downloads involve no per-GB charge anywhere. The full walkthrough — API tokens, read-operation pricing, Infrequent Access retrieval fees — is in Zip R2 Files into a Download Link via API.

Where it stops working — honestly: it's a vendor dependency, and heavy sustained volume is metered (the free tier covers 100 files and 5 GB of output per job). Small, occasional exports don't need a service.

Choosing in one sentence each

  • Scheduled bundles, stock tools, and you accept the egress-on-return bill: mirror + zip in a container (method 1) — or just rclone on your laptop for a one-off.
  • Small bounded archives inside existing infrastructure: Worker or server ZIP (method 2).
  • A download-all button without new infrastructure: presigned URLs + browser ZIP (method 3).
  • Multi-GB, repeat downloads, delivery that must not break: managed ZIP API (method 4) — and on R2, with no bandwidth bill at all.

FAQ

Does R2 have a native "download folder as ZIP"?

No. Neither the dashboard nor the S3-compatible API archives a prefix; every ZIP feature is built on top, with one of the four methods above.

Can a Cloudflare Worker zip a whole bucket?

Not realistically. The 128 MB memory ceiling and CPU-time billing make Workers suitable for small, bounded archives only — this is the main way R2 differs from "just do it serverless" expectations.

Is downloading from R2 really free?

The bytes are — R2 charges $0 egress to any destination. You pay per-operation instead: reads are $0.36 per million after the free allowance, which rounds to nothing at export scale.

Do I need a Worker to give Eazip access to a private bucket?

No. Presigned URLs from the S3-compatible endpoint work from any backend with an R2 API token — no Worker in the path.