← All docs

S3 / R2 destinations

A destination with "type": "s3" delivers directly to a bucket your tenant owns. DataEgress supports real AWS S3 and any S3-compatible provider (Cloudflare R2, MinIO, etc.) via an optional endpoint override.

Creating an S3 destination

await dataEgress.createDestination({
  tenantId: "acme",
  id: "acme-s3",
  type: "s3",
  s3: {
    bucket: "acme-data-lake",
    region: "us-east-1",     // use "auto" for Cloudflare R2
    accessKeyId: "...",
    secretAccessKey: "...",
    prefix: "dataegress/",    // optional
    endpoint: undefined,     // set this for R2 (see below) — omit for real AWS S3
  },
});

Credentials are encrypted at rest (AES-256-GCM) and never echoed back by the API — GET/POST responses always mask secretAccessKey.

Using Cloudflare R2 instead of AWS S3

R2 speaks the S3 API. Set region: "auto" and endpoint to your account's R2 endpoint:

s3: {
  bucket: "acme-data-lake",
  region: "auto",
  endpoint: "https://<account_id>.r2.cloudflarestorage.com",
  accessKeyId: "...",
  secretAccessKey: "...",
}

Required IAM permissions

The access key you register needs exactly this, scoped to the bucket (and prefix, if you're using one) — nothing broader:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:AbortMultipartUpload", "s3:ListMultipartUploadParts"],
      "Resource": "arn:aws:s3:::acme-data-lake/dataegress/*"
    }
  ]
}

s3:PutObject writes the file. s3:AbortMultipartUpload and s3:ListMultipartUploadParts are needed because large exports upload in parts (see Multipart uploads below) — without them, a failed or retried upload can't clean up or resume a part it already started. DataEgress never lists, deletes, or reads back objects other than to generate a short-lived signed URL for your own dashboard preview of what it just wrote, so nothing beyond these three actions is ever needed. See troubleshooting.md if a real upload fails despite this.

Multipart uploads

Files are uploaded via @aws-sdk/lib-storage's Upload class, which automatically switches to multipart above a configured part size (8MB here). This is a deliberate correctness fix, not a nice-to-have: a naive single-PUT upload silently fails past S3's 5GB single-object limit — see GitLab #430277 for a real-world example of exactly this bug shipping to production elsewhere.

Verifying against a real bucket

The fastest way to check a bucket and credentials actually work is to create the destination via the SDK or dashboard (as above) and run one real export against it:

const run = await dataEgress.runExport({
  tenantId: "acme",
  dataset: "orders", // or any dataset you've already registered
  destination: "acme-s3",
  format: "csv",
});

Poll dataEgress.getExport(run.id) until status is "completed" or "failed". A failure's error field names the actual problem (bad credentials, wrong region, bucket doesn't exist, etc.) rather than a generic message. Use a dedicated test bucket (or a disposable prefix within one) for this — a successful run writes a real object at <prefix>exports/<tenantId>/<dataset>/<run-id>.<format> (the same key a retry overwrites rather than duplicates).

Not supported yet

See limitations.md — no GCS, no Azure Blob, no SFTP, no warehouse-native destinations (Snowflake/BigQuery/Redshift/Databricks) in V1. GCS is the most likely near-term addition since it shares most of the S3 destination's code path.

S3 & R2 destinations — DataEgress