Eazip
Guides

Download MinIO Objects as a ZIP

List objects in a self-hosted MinIO bucket, presign each one, and package the results into one ZIP with @eazip/core.

MinIO has no "download folder as ZIP" endpoint, so you list the objects under a prefix on your server, presign each one, and hand the resulting { url, filename } list to @eazip/core. Storage credentials stay on the server; the browser only ever sees short-lived signed URLs and does the archiving itself.

Loading live demo…

Prepare presigned URLs on the server

Your backend endpoint should:

  1. authenticate the application user;
  2. list the objects under the requested bucket and prefix;
  3. authorize the request against those specific objects; and
  4. return short-lived signed URLs, never MinIO access keys.
server/export.ts
import { Client } from 'minio';

const minioClient = new Client({
  endPoint: 'minio.internal.example.com',
  port: 9000,
  useSSL: true,
  accessKey: process.env.MINIO_ACCESS_KEY!,
  secretKey: process.env.MINIO_SECRET_KEY!,
});

type SignedSource = {
  url: string;
  filename: string;
};

function listObjectKeys(bucket: string, prefix: string): Promise<string[]> {
  return new Promise((resolve, reject) => {
    const keys: string[] = [];
    const stream = minioClient.listObjectsV2(bucket, prefix, true);

    stream.on('data', (obj) => {
      if (obj.name && !obj.name.endsWith('/')) keys.push(obj.name);
    });
    stream.on('error', reject);
    stream.on('end', () => resolve(keys));
  });
}

export async function listPrefixAsSignedUrls(
  bucket: string,
  prefix: string,
): Promise<SignedSource[]> {
  const keys = await listObjectKeys(bucket, prefix);

  return Promise.all(
    keys.map(async (key) => ({
      url: await minioClient.presignedGetObject(bucket, key, 15 * 60),
      filename: key.slice(prefix.length),
    })),
  );
}

listObjectsV2 streams results, so it scales to prefixes with many objects without paging logic of your own. Keep the presignedGetObject expiry (in seconds; 900 above) short, but long enough for every file to finish downloading — an expired link only fails that one file, and the archive is still usable if you leave failOnUrlError at its default.

If your backend already standardizes on @aws-sdk/client-s3, MinIO's S3 compatibility means GetObjectCommand plus getSignedUrl from @aws-sdk/s3-request-presigner works too — point endpoint at your MinIO server and set forcePathStyle: true.

Allow browser requests (CORS)

The browser fetches each signed URL directly from MinIO (or from whatever reverse proxy sits in front of it), so that origin must send CORS headers allowing your application's origin.

MinIO Community Edition applies CORS at the server level, not per bucket:

mc admin config set myminio api cors_allow_origin="https://app.example.com"
mc admin service restart myminio

The equivalent MINIO_API_CORS_ALLOW_ORIGIN environment variable works the same way if you configure MinIO through its container or systemd environment instead of mc admin config set. Per-bucket CORS rules set with mc cors set exist only in MinIO's commercial AIStor edition — don't rely on that command against a self-hosted Community Edition server.

Many self-hosted deployments instead put MinIO behind nginx, Traefik, or another reverse proxy. In that case, set the CORS headers on the proxy instead:

nginx.conf
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Access-Control-Allow-Methods' 'GET' always;

Without one of these in place, the browser blocks the fetch and Eazip reports the file as failed even though the signed URL itself is valid.

Create the ZIP in the browser

download-export.ts
import { createZip } from '@eazip/core';

const response = await fetch('/api/exports/bucket-prefix', {
  credentials: 'include',
});

if (!response.ok) {
  throw new Error('Could not prepare export files');
}

const files: { url: string; filename: string }[] = await response.json();

const result = await createZip({
  files,
  zipName: 'minio-export.zip',
});

result.download();

files also accepts plain URL strings, or a mix of URLs and browser File or Blob objects if part of the export comes from a local picker; see Input types for every accepted shape. createZip() resolves once every reachable object has been fetched and packaged, then result.download() starts the browser download. Use startZip() instead when you need live progress or a cancel button; see Create a ZIP from remote URLs for that shape and for how partial results behave when some objects fail.

When to use Eazip Cloud

Local jobs like the one above run entirely in the visitor's tab. Switch to Eazip Cloud when:

  • the prefix totals multiple gigabytes or thousands of objects;
  • MinIO can't be given browser CORS, for example a locked-down internal deployment;
  • the export should survive a page reload; or
  • you would rather not hold archive bytes in tab memory at all.

Cloud accepts the same signed URL list; only the strategy changes:

const result = await createZip({
  strategy: 'cloud',
  publicKey: 'pk_ez_...',
  files,
  zipName: 'minio-export.zip',
});

result.downloadAll();

Add your MinIO hostname to the Public App's allowed source hosts, and make the presigned URL lifetime long enough for Eazip Cloud to start and finish reading each object.

Self-hosted MinIO must be publicly reachable

Eazip Cloud fetches source URLs from its own servers, not from your visitor's browser. If your MinIO instance sits behind a firewall or VPN with no public endpoint, Cloud cannot reach it — Local remains the only option unless you expose a public, authenticated path to those objects.

See When to use Eazip Cloud for the full comparison with Local.

Limits and alternatives

Eazip is built for turning a browser-facing export into one ZIP, not for operator-driven data movement. If you're an administrator moving objects rather than serving an end-user download, mc mirror or mc cp --recursive copies a bucket or prefix straight to local disk without a browser at all — reach for those first.

For end-user downloads, a Local job is limited by the browser tab's memory and the tab staying open. Because MinIO is self-hosted, there's no fixed per-GB egress price like a public cloud provider — cost and throughput depend on your own server, network, and any proxy or CDN in front of it. For anything multi-gigabyte, thousands of objects, or that needs to survive a reload, use strategy: 'cloud' as shown above.

FAQ

Can I download a MinIO bucket or folder as a ZIP without a server?

Not safely for private data. A "folder" in MinIO is just a shared key prefix, and listing or reading it requires access keys or a signed URL, so some backend step has to authenticate the request and presign the objects. If the prefix is fully public and anonymous access is enabled, you could generate the URL list once and cache it, but the listing step still needs credentials somewhere.

Does MinIO support ZIP downloads natively?

No. MinIO stores and serves individual objects and has no operation that returns a prefix as a single archive. The MinIO Console lets a logged-in operator zip objects for their own download, but that's a console feature, not an API your application can call for end users. Eazip does the archiving in the browser or in Eazip Cloud instead.

Why does my MinIO ZIP download fail with a CORS error?

The browser is fetching each signed URL directly from MinIO (or your reverse proxy), and nothing in that path is sending an Access-Control-Allow-Origin header for your app's origin. Set cors_allow_origin on the MinIO server, or add CORS headers at the reverse proxy, as shown above.

Can Eazip Cloud reach a self-hosted MinIO behind my firewall?

Only if you expose a public, authenticated endpoint for it. Cloud fetches source URLs from its own infrastructure, not the visitor's browser, so a MinIO instance reachable only over a private network or VPN is invisible to it. Use Local for those deployments.

Do presigned MinIO URLs expose my access keys?

No. A presigned URL grants time-limited access to one object, signed with your server's access key and secret key; those credentials never reach the browser. Keep the expiry as short as your download flow allows.