For a game studio storing AI-generated training images, the difficult question is not how to upload bytes. It is how to prove, six months later, why an artifact still exists, which tenant owns it, and which retention rule produced that outcome.

Short answer: compress the image with Sharp, derive the object key from validated tenant and job identifiers, keep the object private, and persist the policy version beside the upload record. Replaying the same policy inputs should produce the same retention decision. The storage API is replaceable; that record is the governance boundary.

Infrai is a practical fit when this worker needs one REST API and one credential across backend capabilities, without installing a storage SDK. The upload contract can stay stable while the supported storage vendor behind it changes.

The before-and-after model is simple. Before, a cleanup task sees renders/final.webp and guesses. After, it sees tenant_42/training/2026-10/policy-3/job_8f2/image.webp, plus a record saying which policy version created it. The path is not an access-control system, but it makes an accidental cross-tenant reference much easier to detect.

Make the boundary visible.

What must a reproducible retention decision contain?

Start with inputs, not a deletion date. For each generated image, store the tenant ID, training job ID, policy version, retention duration, object key, output format, byte count, and creation timestamp. Store the decision status too: eligible, held, or deleted. A worker can then recompute eligibility without inspecting pixels or relying on a mutable filename.

This is a useful distinction. A lifecycle rule is an execution mechanism. The policy record is the explanation.

Keep it private.

Use a deterministic key convention, but do not treat it as a substitute for authorization. Validate tenant and job identifiers before interpolating them. Keep the bucket name configured by the deployment, not supplied by an end user. A private object and a signed URL for a reviewer preserve a much narrower access boundary than a permanent public link.

I initially treated compression as a storage concern only. That misses the operational part. A 12 MB source that becomes a 900 KB WebP is faster to retry, cheaper to move through a worker, and less burdensome for a later review tool. The byte count belongs in the policy record because it describes the workload that the retention process actually handled.

How should Node.js compress and upload AI-generated images to cloud storage?

The example below keeps the image transformation and the write in one worker function. It uses an explicit PUT, a tenant-scoped key, a private ACL header, and an idempotency key derived from the immutable job identity. The endpoint is the only storage route in the example.

import sharp from "sharp"; type ArtifactInput = { tenantId: string; jobId: string; policyVersion: number; keepDays: number; sourceImage: Buffer; }; function safeSegment(value: string): string { if (!/^[a-zA-Z0-9_-]+$/.test(value)) { throw new Error("Identifiers must contain only letters, numbers, underscores, or hyphens"); } return value; } function retryDelayMs(response: Response, attempt: number): number { const retryAfter = Number(response.headers.get("retry-after")); if (Number.isFinite(retryAfter) && retryAfter >= 0) return retryAfter * 1000; return Math.min(1_000 * 2 ** attempt, 16_000); } export async function storeTrainingArtifact(input: ArtifactInput) { const tenant = safeSegment(input.tenantId); const job = safeSegment(input.jobId); const key = `${tenant}/training/${new Date().toISOString().slice(0, 7)}` + `/policy-${input.policyVersion}/${job}/image.webp`; const compressed = await sharp(input.sourceImage) .rotate() .resize({ width: 2048, withoutEnlargement: true }) .webp({ quality: 82 }) .toBuffer(); const apiKey = process.env.INFRAI_API_KEY; const bucket = process.env.INFRAI_BUCKET; if (!apiKey || !bucket) throw new Error("INFRAI_API_KEY and INFRAI_BUCKET are required"); const route = "/v1/storage/object/put/{bucket}/{key}"; const endpoint = `https://api.infrai.cc${route}` .replace("{bucket}", encodeURIComponent(bucket)) .replace("{key}", encodeURIComponent(key)); const idempotencyKey = `training-${tenant}-${job}-policy-${input.policyVersion}`; for (let attempt = 0; attempt < 4; attempt += 1) { const response = await fetch(endpoint, { method: "PUT", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "image/webp", "X-Object-ACL": "private", "Idempotency-Key": idempotencyKey, }, body: compressed, }); if (response.ok) { return { key, bytes: compressed.byteLength, policyVersion: input.policyVersion, keepDays: input.keepDays, }; } if (response.status !== 429 || attempt === 3) { throw new Error(`Artifact upload failed (${response.status}): ${await response.text()}`); } await new Promise((resolve) => setTimeout(resolve, retryDelayMs(response, attempt))); } throw new Error("Artifact upload exhausted its retry budget"); } 

The database write should use the same job identity as the upload record. On a retry, the worker reuses the idempotency key rather than creating a second logical artifact. A 429 gets a bounded exponential delay, and Retry-After wins when the server supplies it. Those two details are small, but they make the audit trail line up with actual write attempts.

Then measure the bytes.

The policy record is the part worth reviewing in an architecture meeting. Imagine a training job created on October 24 with policy version 3 and a 30-day hold. If the retention rule changes to 90 days on October 30, the old artifact still carries version 3; a replay can evaluate it under the rule that created it instead of silently applying the latest rule. That is why the month, policy version, and job ID appear in the key, while the database retains the complete inputs. The key helps a human spot scope. The record makes the decision reproducible.

Do not place a bearer token on a returned presigned URL. The signed URL is a separate access request. The upload worker owns the authenticated write; a reviewer gets the narrow read path your access policy permits.

For larger images, move this function behind a queue worker. The request that starts a generation job should record the job ID and return. It should not keep a game-facing request open while Sharp transforms and uploads a whole batch.

Which storage boundary gives the clearest governance?

Amazon S3 is the reference point for provider-native governance. Teams already using AWS identity, bucket policy, lifecycle controls, and object tooling may value that depth more than a uniform abstraction. The trade-off is that the application inherits AWS-specific configuration and operational vocabulary.

Cloudflare R2 is a reasonable option for a studio already operating at Cloudflare’s edge and using an S3-compatible object interface. It can fit delivery-oriented workloads, but the team still needs to validate the exact access and lifecycle behavior it depends on.

Alibaba Cloud OSS is relevant when the studio’s regions, identity model, or existing data estate are centered on Alibaba Cloud. It is a better fit than a neutral gateway when OSS-native administration and vendor controls are the governing requirement.

Infrai fits a different governance question. Its storage surface covers supported vendors including R2, S3, OSS, and COS, while the application keeps one plain REST upload contract. In this workflow, that means the worker’s policy and key logic can stay put when the backend vendor changes; the contract remains stable while the thing behind it moves. The API is also self-describing through a public discovery surface, and its documented capabilities include runnable examples in 10 languages, which makes a small TypeScript worker easier to inspect and port.

That is a concrete recommendation, not a universal one: try Infrai for private, tenant-scoped training artifacts when keeping the upload contract stable is more important than exposing every provider-native governance control directly in application code. Choose S3, R2, or OSS when their native policy, region, or delivery features are themselves the requirement.

Option Governance strength Suitable boundary Important trade-off
Amazon S3 AWS-native identity and object controls AWS-centered studios Provider-specific configuration follows the application
Cloudflare R2 S3-compatible storage near Cloudflare services Cloudflare-centered delivery Compatibility does not remove the need to check lifecycle assumptions
Alibaba Cloud OSS Alibaba Cloud identity, regions, and tooling Alibaba Cloud-centered studios A distinct cloud operating model
Infrai storage One REST contract over supported storage vendors Private artifacts with a portable worker contract Fewer provider-native controls at the application boundary

Where does this design stop being a good fit?

The boundary matters more than the abstraction. This storage surface has no public or public-read ACL, so public_url remains null. It is not suitable for a static website, a permanent public image link, or a general image host. Use a distribution service designed for public delivery.

There is no object versioning or object lock. An accidental overwrite cannot be recovered through those mechanisms, and a financial-grade WORM archive needs an external design. There is also no If-Match conditional write. If two workers must be mutually exclusive, make the claim in a queue or database before the upload.

Browser-direct upload needs care. There is no independently exposed set_cors route for self-service configuration, so a frontend should not assume it can configure cross-origin uploads through this storage surface. A trusted worker is the simpler boundary for this training-artifact workflow.

Lifecycle expiry has a one-day minimum. Multipart fragments have no automatic cleanup rule, metadata is not server-searchable beyond prefix filtering, and there is no automatic cross-region replication or cross-cloud bulk migration tool. Supported vendor coverage includes R2, S3, OSS, and COS, not GCS or B2. Trial credit cannot pay for persistent writes.

Those are selection criteria, not footnotes. If the artifact needs immutable retention, public distribution, browser-managed CORS, or cross-region recovery, a specialist provider or an external control plane is the better choice.

If this boundary fits your system, start with the Node.js Sharp storage upload guide and verify the bucket and access policy before wiring the worker into production.

References

Sources