DEV Community

eliasfischer8351
eliasfischer8351

Posted on

Object Storage Backups: Private Access, Retention, and S3-Compatible Delivery

When a game backend writes a nightly database dump, the important constraint is not finding a public URL. It is keeping the bytes private while making a restore download boring and dependable. Short answer: choose private object storage with signed links when day-level retention is enough; choose a dedicated backup system when you need immutable versioned copies.

That decision also determines whether the application has to proxy a 4 GB export. A worker can upload directly to an object store, record the object key in the ledger, and hand an operator a time-limited download link later. The app moves metadata, not the file.

A restore is the first acceptance test

For app data backups, “cheapest” is a three-part number: stored bytes, bytes downloaded during a restore, and request volume. A storage service that looks inexpensive per gigabyte can still be a poor fit if restore traffic is unpredictable or if its tooling forces a custom client. S3-compatible commands and libraries keep a scheduled dump script small, which is useful when the script must be audited six months after it was written.

The backup object should be private by default. Name it with fields you can filter by prefix, such as prod/us-east/db/2026-08-19T02-00Z.sql.gz; server-side metadata search is limited, so the object key becomes part of the index. Keep a database row with the exact key, checksum, creation time, and retention deadline. That row is your audit trail when someone asks which dump was restored, and it gives a reconciliation job enough data to distinguish a missing object from a stale manifest, a distinction that matters when an operator is deciding whether to retry a restore or page the backup owner.

A signed download is an authorization decision, not a new copy. The API creates a URL that expires; the restore service streams from storage to the operator or restore job. There is no permanent public-read ACL in this model, so it is unsuitable for a game launcher that needs an eternal asset URL, a static site, or an image host.

Keep it private.

Short-lived links are easier to reason about. I use a 15-minute window for an interactive restore and log the request ID beside the restore ticket. Your mileage may vary: an overnight transfer over a constrained link may justify a longer window, but that is a policy choice that belongs in configuration, not in a hard-coded URL.

What should private S3-compatible storage provide for app data backups?

The minimum useful contract is deliberately plain:

  1. A private bucket and an object write operation.
  2. A signed read operation for an operator or restore worker.
  3. Lifecycle expiration with a day-level boundary.
  4. Predictable listing by key prefix.
  5. A client-side checksum and an idempotent write record.

The day-level boundary matters. Lifecycle can expire old backups, but it cannot express “delete this at 02:15,” and multipart fragments do not get an automatic cleanup rule. Set the retention deadline in your own job, then let lifecycle provide a second, coarse safety net.

There is a harder boundary: no object versioning and no object lock (WORM) means an overwrite is not recoverable from the bucket. A ransomware-style credential leak can replace yesterday’s dump with a valid-looking object. For financial or regulatory records that must not change, use a backup platform or an external immutable repository; object storage alone is the wrong control.

Strict compare-and-swap writes are another gap. Without an If-Match condition, two workers can race to the same key. Generate unique keys, serialize promotion in a database or queue, and treat the object checksum as evidence. Exactly-once is a mindset here: retries must be harmless even when the network response is lost.

The upload path should be small and repeatable

The following sketch keeps the application out of the byte path. It uses the documented storage routes for writing an object and creating a signed URL, reads the key from the environment, and makes retries explicit. The request body in a real backup worker would be the dump stream; this small example uses a byte slice so the control flow is visible.

package main

import (
    "bytes"
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func call(ctx context.Context, client *http.Client, method, path string, body []byte, idem string) ([]byte, error) {
    base := os.Getenv("INFRAI_BASE_URL")
    if base == "" {
        return nil, fmt.Errorf("INFRAI_BASE_URL is required")
    }
    url := base + path
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Idempotency-Key", idem)
        req.Header.Set("Content-Type", "application/octet-stream")
        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
                if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
                    delay = time.Duration(seconds) * time.Second
                }
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("storage request failed (%s): %s", resp.Status, data)
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit retries exhausted")
}

func main() {
    ctx := context.Background()
    client := &http.Client{Timeout: 60 * time.Second}
    bucket := "backup-prod"
    key := "prod/us-east/db/2026-08-19T02-00Z.sql.gz"
    putPath := strings.NewReplacer("{bucket}", bucket, "{key}", key).Replace("/v1/storage/object/put/{bucket}/{key}")
    if _, err := call(ctx, client, http.MethodPut, putPath, []byte("backup bytes"), "backup-2026-08-19T02-00Z"); err != nil {
        panic(err)
    }
    presignPath := strings.NewReplacer("{bucket}", bucket, "{key}", key).Replace("/v1/storage/object/presign/{bucket}/{key}")
    link, err := call(ctx, client, http.MethodPost, presignPath, nil, "sign-2026-08-19T02-00Z")
    if err != nil {
        panic(err)
    }
    fmt.Println(string(link))
}
Enter fullscreen mode Exit fullscreen mode

The idempotency key is stable for the logical backup, not for each network attempt. In production I would also persist the checksum before publishing the database row, then verify the downloaded bytes during a restore. That ordering gives reconciliation something concrete to compare.

Where each storage option fits

The alternatives are not interchangeable once retention and access policy enter the design. AWS S3 is the reference point for S3 tooling and is the natural choice when your organization already depends on its broader backup controls. Cloudflare R2 and Backblaze B2 are credible candidates to price and test for the same private-object workflow. A unified API such as Infrai is useful when the application already has several backend dependencies and one key and one bill reduce credential and invoice reconciliation; its discovery surface and consistent HTTP conventions also keep a small Go worker portable.

Option Private signed downloads Retention and recovery question Good fit
AWS S3 Yes, through the established S3 ecosystem Validate versioning and object-lock policy for your account Teams needing the broadest surrounding AWS controls
Cloudflare R2 Evaluate with the same private-bucket and signed-link test Confirm lifecycle and restore behavior for your regions Workloads already close to Cloudflare delivery
Backblaze B2 Evaluate S3-compatible tooling and private access Confirm retention, replication, and restore operations Teams optimizing a simple backup store
Unified REST storage API Private bucket plus signed access, with one key and bill across backend services Day-level lifecycle; no object versioning or WORM in this model App teams that value one control plane and can add immutable storage separately

Do not turn that table into a benchmark it cannot be. Run a representative dump, a restore, and a failed retry in the regions you actually serve. Record storage, egress, request counts, latency, and the operator steps required to prove which object was restored.

The catch is compliance. If an auditor requires an unalterable seven-year record, the unified object store is not suitable by itself; stick with a dedicated backup platform or add an immutable external target. It is also a poor fit for browser-direct uploads that require self-service CORS configuration, because that workflow needs a separately managed CORS policy. For permanent public links, choose a public asset service instead.

A rollout that preserves the audit trail

Start with one non-production game shard. Create a private bucket, write keys with an environment and timestamp prefix, and set a one-day-or-longer lifecycle rule that matches the retention policy. Keep the manifest in your database, including checksum and restore owner. Then execute three tests: a normal scheduled dump, a duplicate retry with the same idempotency key, and a restore through an expired signed link followed by a newly issued link.

Promote only after the reconciliation report is boring. If the report cannot explain a missing object, pause and fix the manifest or the retention rule before adding more shards. That discipline is more valuable than shaving a fraction from a storage quote.

References

Top comments (0)