For a media app, keep each tenant's backups in a private object-storage namespace, make the snapshot key immutable, and let a lifecycle rule expire ordinary snapshots after 30 days. The important decision is tenant isolation, not the deletion syntax: a restore must never be able to select another customer's bytes, and an expiry rule must not be mistaken for an immutable archive.
That sounds obvious until the backup worker, bucket policy, and restore endpoint each invent a slightly different idea of ownership. The failure is usually quiet. A tenant ID is missing from one prefix, a cleanup job receives an unvalidated key, or a restore list is filtered after objects have already been exposed to the application.
Short answer: use a private bucket, a server-generated tenant-scoped key, and bucket-side lifecycle expiry for a normal 30-day backup window; use a separate retention or archival design when deletion must be prevented or when each tenant needs a different legal hold.
The decision record: invariants before configuration
The system has four invariants.
First, the client never chooses the storage namespace. The authenticated service resolves the tenant and constructs the key. Second, a completed backup is addressed by a unique key rather than overwritten in place. Third, restore authorization is checked before the object is read, not after a broad listing has been returned. Fourth, lifecycle deletion is an operational policy, while backup validity is established by a manifest and a restore test.
The distinction between a stored object and a usable recovery point matters. A successful upload proves that a storage service accepted bytes. It does not prove that the database dump is complete, that all media parts are present, or that the application can rebuild its indexes. Record the snapshot ID, tenant ID, creation time, format, checksum, and completion state in a database that is separate from the object key. Mark the record complete only after the manifest and every expected object have been verified. In practice, that means a worker can upload six video segments, lose its process after segment five, and leave behind a perfectly ordinary-looking prefix; the restore catalog must still call that snapshot pending, and the lifecycle rule must not be allowed to define “complete” by implication. A reconciliation pass should compare the manifest with the object listing, preserve the mismatch as an alert, and make the operator decide whether to retry or discard it.
Make it boring.
The key should carry enough structure for a human and a lifecycle rule to understand it without opening the archive:
from datetime import UTC, datetime from uuid import uuid4 def snapshot_key(tenant_id: str, snapshot_kind: str) -> str: created_at = datetime.now(UTC).strftime("%Y-%m-%dT%H%M%SZ") snapshot_id = uuid4().hex return f"backups/{tenant_id}/{snapshot_kind}/{created_at}-{snapshot_id}.tar" print(snapshot_key("tenant_042", "media")) The tenant identifier in this example is an internal identifier, not a customer name. Validate it against the authenticated request and allow only the character set your policy expects. Do not accept ../, an arbitrary bucket name, or a complete object path from a browser form. OWASP's file-upload guidance is useful here because backup ingestion has the same dangerous boundary: untrusted names and content must not become storage policy.
How should a Node.js app isolate private backups and expire them after 30 days?
Treat the bucket policy and the application authorization check as two different controls. The bucket remains private. The service account used by the backup worker can write only to the intended backup area, while the restore service can read only after it has established that the requested snapshot belongs to the caller's tenant. A signed download URL, when used, should be short-lived and created only after that check; it is not a replacement for authorization.
The Node.js application can use any storage client that supports the chosen object-storage protocol, but the policy should live in deployment configuration rather than in a request handler. A lifecycle rule should target the exact backup prefix and expire objects after 30 days according to that storage system's documented age semantics. “Thirty days” needs an explicit definition in the runbook: age from upload, age from a timestamp, or a calendar boundary. Your mileage may vary across storage systems, especially around delayed lifecycle processing, so test the actual deletion window before promising an exact minute to a customer.
The critical path is deliberately boring. The worker creates a pending snapshot record, writes objects under its server-generated prefix, verifies the manifest, and then marks the record complete. The restore path queries completed records for the authenticated tenant, checks the requested snapshot ID, and reads only the resulting key.
from dataclasses import dataclass @dataclass(frozen=True) class Snapshot: snapshot_id: str tenant_id: str object_prefix: str state: str def authorize_restore(snapshot: Snapshot, requested_tenant: str) -> str: if snapshot.tenant_id != requested_tenant: raise PermissionError("snapshot does not belong to this tenant") if snapshot.state != "complete": raise ValueError("snapshot is not a recovery point") return snapshot.object_prefix This is an application contract, not a storage SDK. The point is to make the dangerous branch visible: a tenant mismatch must stop the restore before any object read. A list operation that returns every tenant's keys and relies on a later UI filter has already crossed the wrong boundary.
What fails when retention, bucket policy, and restore selection disagree?
The most damaging failure mode is cross-tenant selection. It can come from a missing prefix component, a predictable snapshot name, or a restore endpoint that accepts an object key instead of a snapshot ID. None of those failures is fixed by making the bucket private; a privileged backend can still read the wrong object. Test the authorization matrix with two tenants and verify that a valid snapshot ID from one tenant is indistinguishable from “not found” to the other.
The second failure mode is deleting the only verified copy. A lifecycle rule sees objects, not recovery objectives. If the database record says a backup is complete but its manifest is absent, automatic expiry cannot repair that inconsistency. Keep a small reconciliation job that compares completed records with manifests and emits an alert well before day 30. It should report and quarantine suspicious state rather than silently deleting or rewriting an archive.
The third is assuming expiration is immediate. Lifecycle processing is usually asynchronous, and the exact behavior is storage-system-specific. A 30-day policy should therefore be treated as a retention target with a documented processing tolerance unless the selected platform contract says otherwise. Never use a lifecycle rule as the only mechanism for a temporary restore export that must disappear after an hour; that requires an application-owned deletion workflow and an audit trail.
There is also a security failure that looks like convenience: using original filenames or MIME types as trust signals. Media uploads can carry misleading names and content types. Store generated names, inspect content according to the backup format, limit archive size, and log who initiated each restore. The object store is a byte container, not a validator of business meaning.
Comparing the ownership choices
The right owner for time depends on the policy you need. Here is the decision boundary I would put in the architecture record.
| Control owner | Good fit | Cost or limitation |
|---|---|---|
| Bucket lifecycle rule | One ordinary retention window for completed snapshots | Expiry is asynchronous and does not validate a backup or understand tenant records |
| Application cleanup worker | Per-tenant schedules, legal holds, or short-lived restore exports | The application owns retries, pagination, auditability, and deletion correctness |
| Immutable retention system | Evidence or archives that must not be deleted during a stated period | More operational coordination; the retention lock must be established deliberately |
| Replicated backup arrangement | Recovery from a storage-region or account loss | Replication adds policy and testing work; it does not by itself prove a restore succeeds |
I would reject an application delete job as the primary mechanism for a plain 30-day window. It makes the application responsible for a clock that the storage layer can enforce, and a broken application is exactly when its backups become important. I would choose that worker when the requirement is not plain age-based retention: a customer may pause deletion, a restore export has a short deadline, or a database record determines whether an object is still needed.
The catch is immutability. A lifecycle policy that eventually deletes an object is unsuitable when a regulator, contract, or incident process requires a backup to remain undeletable for a defined period. Select a storage control with that property and document who can place and release a hold. Do not label ordinary private storage “immutable” because its access policy is restrictive.
Verification is part of the storage design
Before deployment, test the policy with a disposable tenant namespace. Upload a complete snapshot, an incomplete snapshot, and an object outside the intended prefix. Confirm which objects the lifecycle rule selects, then exercise restore authorization with two tenant identities. Check that deletion events, denied reads, manifest mismatches, and restore starts are observable without logging archive contents or credentials.
After deployment, measure three separate things: backup completion rate, age of the oldest object that should have expired, and successful restore rate. A green upload metric is not a green recovery metric. Keep a runbook that names the retention clock, the expected lifecycle delay, the escalation owner, and the approved place for an exceptional hold.
I'm not sure a single 30-day window is correct for every media tenant. Raw video, derived thumbnails, and metadata often have different recovery value, but the policy should follow an explicit recovery objective rather than storage convenience. Start with one conservative class, prove the restore path, and split retention only when the operational owner and audit rule are clear.