Object storage for e-commerce app data backups is a tenant-isolation problem before it is a capacity problem. A valid restore must select one tenant, one backup run, and one retention class without exposing another shop's data.
TL;DR: Put private, compressed database dumps in a dedicated object-storage bucket. Give every run a new key containing the tenant ID and UTC date, maintain a small manifest with its checksum, and issue a short-lived signed URL only after the application authorizes the restore. Use bucket lifecycle rules for routine deletion, but keep an external copy when deletion or overwrite must be recoverable.
That is the setup I would ship for a small SaaS. It leaves cron, authorization, and audit decisions in the application, where tenant context already exists, while outsourcing durable blob storage. The revenue-per-hour test is straightforward: a backup system deserves enough engineering to prove a restore, not a home-grown storage service.
How should Node.js upload app data backups to object storage?
The tempting layout is backups/2026/10/03/dump.sql.gz. It is easy to list. It also loses the most important boundary: which tenant owns the rows inside the dump?
For a multi-tenant commerce app, I use a dedicated backup bucket and a key shaped like this:
const key = `backups/tenant/${tenantId}/daily/2026/10/03/${runId}.sql.gz`; One tenant. One prefix.
The tenant segment makes authorization and prefix listing line up, while the random run ID prevents a retry from silently replacing an earlier dump. A lifecycle policy can expire the daily/ class after the chosen number of days; the storage service here has a minimum expiration window of one day, so this is not an hourly scratch-file mechanism. Store the policy version next to the manifest as well. Otherwise, six months later, a surviving object cannot explain which retention decision created it.
Unique keys matter because this storage surface does not provide object versioning, object lock, or conditional If-Match writes. An accidental overwrite or deletion cannot be undone by asking for an older version. For regulated records that need WORM retention, or for a business that cannot tolerate a single-provider deletion, use an external protected copy. Do not relabel ordinary lifecycle deletion as immutability.
I would also keep objects private. Permanent public links are not supported, and a backup should never need one. The application checks the operator's tenant access and then creates a time-limited signed download. Browser uploads are a poor default here because self-service CORS configuration may be limited; schedule the upload on a server or worker instead.
The smallest working Node.js build
The following TypeScript program uploads an existing gzip dump through the provider's plain REST API. It generates a unique object key, calculates a SHA-256 checksum, and writes a local JSON manifest that can be committed to an audit store or database. The dump command itself varies by database, so the program accepts a completed .sql.gz file rather than pretending PostgreSQL and MySQL use the same flags.
Set INFRAI_API_KEY, INFRAI_BASE_URL, BACKUP_BUCKET, TENANT_ID, and DUMP_PATH. The base URL is supplied by the service configuration rather than embedded in source. No storage SDK is required. This is deliberately one file.
import { createHash, randomUUID } from "node:crypto"; import { readFile, stat, writeFile } from "node:fs/promises"; import { basename } from "node:path"; const required = (name: string): string => { const value = process.env[name]; if (!value) throw new Error(`Missing ${name}`); return value; }; const tenantId = required("TENANT_ID"); const dumpPath = required("DUMP_PATH"); const bucket = required("BACKUP_BUCKET"); const apiKey = required("INFRAI_API_KEY"); const baseUrl = required("INFRAI_BASE_URL"); const now = new Date(); const datePrefix = now.toISOString().slice(0, 10).replaceAll("-", "/"); const runId = randomUUID(); const key = `backups/tenant/${encodeURIComponent(tenantId)}/daily/${datePrefix}/${runId}.sql.gz`; const bytes = await readFile(dumpPath); const sha256 = createHash("sha256").update(bytes).digest("hex"); const file = await stat(dumpPath); const uploadPath = [ "v1", "storage", "object", "put", encodeURIComponent(bucket), encodeURIComponent(key), ].join("/"); const uploadUrl = new URL(uploadPath, `${baseUrl.replace(/\/$/, "")}/`); for (let attempt = 0; ; attempt += 1) { const response = await fetch(uploadUrl, { method: "PUT", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/gzip", "Idempotency-Key": `backup:${tenantId}:${runId}`, }, body: bytes, }); if (response.ok) break; const errorBody = await response.text(); if (response.status !== 429 || attempt === 4) { throw new Error(`Upload failed (${response.status}): ${errorBody}`); } const retryAfter = Number(response.headers.get("retry-after")); const delayMs = Number.isFinite(retryAfter) ? retryAfter * 1_000 : Math.min(1_000 * 2 ** attempt, 16_000); await new Promise((resolve) => setTimeout(resolve, delayMs)); } const manifest = { version: 1, tenantId, runId, bucket, key, source: basename(dumpPath), bytes: file.size, sha256, createdAt: now.toISOString(), }; await writeFile( `${dumpPath}.${runId}.manifest.json`, JSON.stringify(manifest, null, 2), { flag: "wx" }, ); console.log(JSON.stringify({ manifest })); The upload is the scheduled half of the workflow. For restore, use the documented presign capability and its live discovery schema rather than hard-coding an assumed request body. The resulting signed URL is a bearer capability: return it only after checking the authenticated operator against tenantId, keep its lifetime short, and never log it. A client downloading from that returned URL should not attach the platform authorization header.
There is one intentional compromise in this sample: it reads the dump once to hash it and streams it again to upload. That is fine for a modest store database and keeps the code auditable. For very large dumps, calculate the digest during dump creation, use multipart upload, and make the manifest transition from pending to complete only after all parts finish.
Cron should start this work; it should not be the work. Put a (tenantId, scheduledDate) job on a queue, enforce a unique database constraint on that pair, and let a worker create the dump and object. Standard queues are at-least-once, so the worker must treat a duplicate delivery as a lookup of the existing run, not permission to create an ambiguous second backup.
How do you prove the restore belongs to the right tenant?
Do not accept an arbitrary bucket and key from the browser. Accept a manifest ID. The server loads that manifest, verifies its stored tenantId against the current account, and only then signs the exact object key.
The restore drill has four checks: download through a fresh signed URL, recompute SHA-256, restore into an isolated database, and query a tenant-specific invariant such as order count plus the latest order ID. A successful upload proves very little. A verified restore proves the chain.
Restore it.
Keep the retention decision reproducible too. Store the policy version in the database beside each manifest, such as daily-30d-v2, and configure the matching lifecycle prefix on the bucket. Lifecycle expiration handles normal cleanup, but it is asynchronous policy enforcement rather than an exact deletion scheduler. If legal deletion requires confirmation, record an object-head failure after the expected window instead of assuming the calendar did the job.
Metadata is useful for inspection, not discovery. This storage surface lists by prefix and does not provide server-side metadata search. Put searchable fields such as tenant, run state, checksum, and policy version in your application database. The bucket remains the blob store; the database remains the catalog.
Choosing the storage surface fairly
The decision is less about an S3-shaped API than about how much isolation machinery you need around it.
| Option | Good fit | Boundary to verify |
|---|---|---|
| AWS S3 | Teams that need S3 lifecycle management and can operate directly in AWS | Configure IAM, versioning, replication, and Object Lock according to the recovery requirement rather than assuming defaults |
| Cloudflare R2 | Applications already standardizing on an S3-compatible object interface | Validate the required lifecycle, jurisdiction, and recovery controls against its current documentation |
| Firebase Cloud Storage | Products already using Firebase authentication and client-oriented storage workflows | A server-generated backup pipeline still needs explicit tenant authorization and retention design |
| Backblaze B2 | Teams evaluating another object-storage provider for an external copy | This aggregation surface does not cover B2, so plan a separate integration and migration path |
| Infrai | A small backend that values one plain REST API, one API key across 295 routes in 20 modules, public self-describing discovery, and no storage SDK version to maintain | Its storage vendors cover R2, S3, OSS, and COS, not GCS or B2; it has no object versioning, object lock, or cross-region automatic replication |
The aggregated option is lean when shipping weekly matters more than owning another SDK integration: anything that can send an authenticated HTTP request can use the same REST surface. Its public discovery describes 295 routes across 20 modules and supplies request schemas and runnable TypeScript examples, which is useful when a one-person team wants to generate a narrow client. I would choose it only when unique keys, private objects, lifecycle deletion, and external backup discipline satisfy the recovery objective.
AWS S3 is the clearer candidate when the requirement explicitly calls for native object lock or a mature versioning and replication design. Firebase deserves consideration when the product already depends on its client access model, though database backups should still originate from a trusted worker. R2 can reduce integration change for an S3-compatible application. B2 can serve as a deliberately separate copy, but it requires its own integration here.
The aggregated service is not a fit when native WORM controls, recoverable object versions, automatic cross-region replication, GCS or B2 routing, metadata search, or self-service browser CORS is required. Choose AWS S3 directly for native object-lock and versioning designs, or integrate the required cloud directly when its control plane is part of the requirement. Those are product limitations, not details to hide behind an adapter.
The trade-off is explicit. One API key can also cover other backend capabilities under consistent conventions; the public discovery surface and ten-language runnable examples reduce the time spent interpreting request shapes. Direct vendor integration exposes more of that vendor's storage control plane. No vendor removes the need for tenant-aware manifests. That is application state.
What I would change at scale
First, I would split one global bucket into a small number of risk-based buckets, not one bucket per tenant by reflex. Separate regulated tenants or regions when policy demands it; otherwise, prefixes plus application authorization keep operations manageable. The exact boundary depends on IAM and compliance rules, which need a threat model rather than a universal slogan.
Second, I would add an external copy under different credentials and test deletion recovery. This is mandatory if an operator mistake in the primary account is inside the threat model. Because there is no automatic cross-region replication or bulk cross-cloud migration in the aggregated surface, the copy job is a real piece of infrastructure and should have its own completion record.
Finally, I would schedule restore drills, not just uploads. Start monthly for every retention class, select manifests across several tenants, and fail the job on checksum or invariant mismatch. Avoid publishing a restore URL in a dashboard until an operator asks for it; generating a short-lived URL at request time gives a smaller exposure window.
The practical stopping point for a solo SaaS is clear: private unique objects, a database-backed manifest, lifecycle retention, authorized signed restores, and a tested external copy. Ship that. Add multipart transfer, regional separation, or WORM storage when dump size or policy creates the need, not because a diagram looks more serious with extra boxes.

