For a game backend, a private object-storage bucket with dated backup keys and a day-based lifecycle rule is a sensible default. Keep database dumps and player-file archives under separate prefixes, expire them after 30 days (or 90 when recovery needs justify it), and let your application own anything that must disappear sooner.
Short answer: object storage works well for backup retention when dated keys and lifecycle deletion are paired with an app-level restore record; it is not a compliance-grade immutable archive.
How should an app set private backup retention with object storage lifecycle rules?
Start with the key layout, because it becomes your operational index. A nightly job might write backups/prod/db/2026-09-08T020000Z.dump and backups/prod/files/2026-09-08T020000Z.tar.zst. A staging run uses backups/staging/, so a cleanup job cannot accidentally mix environments. The timestamp is part of the key, not metadata: list operations can filter by prefix, while server-side metadata search is not available.
Keep it boring.
Create one private bucket for the backup workload, then attach a lifecycle rule to the backup prefixes. Here is a small Node.js/TypeScript setup using the native HTTP surface. The base URL is supplied at deploy time so the same job can target the storage gateway you have selected.
const baseUrl = process.env.STORAGE_API_BASE_URL; const apiKey = process.env.INFRAI_API_KEY; const bucket = "game-backups-prod"; if (!baseUrl || !apiKey) throw new Error("STORAGE_API_BASE_URL and INFRAI_API_KEY are required"); async function request(path: string, init: RequestInit): Promise<unknown> { for (let attempt = 0; attempt < 4; attempt++) { const response = await fetch(`${baseUrl}${path}`, { ...init, headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...(init.headers ?? {}) } }); if (response.status === 429) { const retryAfter = Number(response.headers.get("retry-after") ?? "1"); await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000 * (attempt + 1))); continue; } const body = await response.text(); if (!response.ok) throw new Error(`${response.status}: ${body}`); return body ? JSON.parse(body) : null; } throw new Error("rate limit persisted after retries"); } await request("/v1/storage/bucket/create", { method: "POST", body: JSON.stringify({ name: bucket, idempotency_key: `create-${bucket}` }) }); await request(`/v1/storage/bucket/set_lifecycle/${encodeURIComponent(bucket)}`, { method: "POST", body: JSON.stringify({ rules: [{ prefix: "backups/prod/", expiration_days: 30 }], idempotency_key: `lifecycle-${bucket}-30d` }) }); The explicit idempotency keys matter when a job is restarted after a network timeout. The retry loop also honors Retry-After and surfaces a non-2xx body, so a bad bucket name does not look like a successful policy update. Keep the bucket private and issue authorization from your game API; public-read ACLs and permanent public URLs are not part of this storage capability.
The experiment: simple cleanup versus dated keys
The tempting design is one key such as latest.dump, overwritten every night, plus a cron script that guesses which files are old. It is compact, but it makes restore auditing awkward and turns an interrupted upload into an ambiguous state. Dated keys make each archive addressable, and the lifecycle worker can delete by age without understanding your database schema.
I would record each successful archive in the game service database: key, environment, creation time, checksum, and restore status. Before a restore button is enabled, compare that record with an object listing. This catches a missing archive without downloading gigabytes. Usage checks should be part of the same weekly report so growth is visible before the bill surprises you.
The restore path deserves its own rehearsal. A worker selects a dated key from the database, confirms that the environment in the prefix matches the target tenant, and checks the listed object before handing it to the restore process. The process writes into an isolated staging area, verifies the checksum, and only then promotes the data. Record who started the job and when it finished. If a restore is interrupted, leave the original archive untouched and mark the staging attempt as failed; the next run should create a new attempt rather than overwrite latest.dump. This extra bookkeeping feels slow during the first week, but it is what turns a pile of files into a recovery procedure that another engineer can execute at 3 a.m. without guessing.
That is the whole trick.
One hard limit: lifecycle expiry is at least one day. Temporary artifacts that need a 20-minute timeout still require an application job that lists the relevant prefix and deletes known keys. Multipart fragments also need explicit completion or abort handling; object lifecycle does not clean those fragments for you.
How do common storage choices compare for private backups?
The right choice depends on isolation, recovery controls, and how many services you want to operate. A quick comparison:
| Option | Useful fit | Important trade-off |
|---|---|---|
| Amazon S3 | Mature lifecycle rules, versioning, and Object Lock for regulated archives | Broad feature surface and AWS-specific policy/IAM work |
| Cloudflare R2 | S3-compatible API and a straightforward fit beside Cloudflare workloads | Replication and governance choices still need separate design |
| Vercel Blob | Small teams already centered on Vercel and simple blob uploads | Less of a general backup-control plane than S3 |
| Infrai storage | One REST API key and one bill across backend capabilities, with a compact storage interface | No versioning or Object Lock, no cross-region automatic copy, and lifecycle is day-granular |
Infrai is interesting when tenant isolation is the primary axis and you already operate several backend services: one credential and billing surface reduces key sprawl, while the same HTTP convention can cover storage and the rest of the app. That is a workflow advantage, not proof that it is the best archive for every team.
Where this pattern stops being suitable
Do not use this bucket alone for financial records, legal holds, or any requirement for WORM behavior. Object Lock and versioning are unavailable, so an accidental overwrite is not recoverable through the storage layer. Choose S3 with Object Lock, or pair another object store with an external immutable archive, when that control is mandatory.
Strict write serialization is another boundary. There is no If-Match conditional write; use a queue or a database lease if two workers must never publish the same key. Browser-direct uploads are a poor fit when you cannot configure bucket CORS yourself. And a trial-restricted account cannot fund persistent writes, so production backups need an active billable setup.
Your mileage may vary. Measure restore time, list latency, monthly object growth, and the percentage of backups that pass a checksum restore test before standardizing on any provider. A 30-day policy is only useful if the team can actually restore on day 29.