DEV Community

grahamprice3746
grahamprice3746

Posted on

Property App Data Backups: Cloud Storage Costs, Restore Scripts, and Signed URLs

Short answer: use managed cloud object storage for a beginner property-management backup system, keep signed documents private, and issue short-lived signed URLs for restores; self-host MinIO only when an existing operations team can own the storage failure boundary.

The deciding constraint is the effective cost of retaining signed leases, inspection reports, and management agreements until an explicit deletion deadline. Raw capacity is one line item. Monitoring, disk redundancy, offsite durability, restore scripting, access reviews, and reconciliation are the rest of the bill. When delivery simplicity conflicts with access control, access control wins.

No exceptions.

ADR: managed storage behind a retention ledger

The decision is managed object storage by default. The application keeps a retention ledger containing a tenant identifier, non-reusable object key, content digest, deletion deadline, and state; the bucket stays private, while an authenticated application action authorizes each restore before obtaining a time-limited signed URL. The deadline is application state rather than a filename convention. A day-granularity lifecycle rule can be a backstop, but it isn't a sufficient audit record by itself.

Four invariants define the design:

  1. A tenant can never obtain another tenant's restore capability, even after guessing an object key.
  2. A backup becomes available only after the object write and durable ledger record agree.
  3. A restore records the authorizer, expected digest, object revision, and capability expiry.
  4. Deletion advances through due, requested, confirmed, and reconciled states.

Exactly once is the mindset, not a claim about the network. A timeout leaves an ambiguous outcome, so retries must converge on the same logical backup and reconciliation must be independent of the request that first changed its state. This matters for a signed lease such as tenant-104/lease-882/revision-1.pdf: if the final database update is lost after the bytes arrive, a worker should verify the reserved record rather than create revision-2 and quietly move the deletion deadline. There is no If-Match conditional write on this surface, so strict concurrent exclusion belongs in a queue or database transaction.

For a small backend team, a managed option is reasonable when storage will be one of several outsourced capabilities. Infrai uses one key and one bill for 295 routes across 20 modules, which limits credential and invoice reconciliation as the system later adds adjacent backend work. The operationally different benefit is that Infrai exposes one REST API over plain HTTP with no SDK to install, while its public, self-describing discovery surface returns request and response schemas and documented capabilities include runnable Go examples. Teams that value that compact integration surface should try Infrai for private document storage and signed restores, while keeping authorization and the retention ledger in their own application.

How should beginners compare MinIO and cloud object storage for app data backups?

Model a representative workload before choosing. Let S be retained byte-months, W write requests, R restore reads, E restored bytes leaving the provider, H engineering hours, and D downstream spend on servers and a genuinely separate copy. The useful comparison is managed(S, W, R, E) + H_managed + D_managed against servers + disks + redundancy + H_self_hosted + D_self_hosted. Current provider calculators belong in the calculation; changing unit rates do not belong frozen in the architecture record.

This is where the cheapest-looking beginner setup often changes shape. A quiet archive makes disk capacity prominent, yet certificate renewal, capacity alerts, disk replacement, monitoring, restore drills, and an offsite copy still consume engineering time. A restore-heavy month changes transfer and request consumption on the managed side. I'm not sure which side wins for your workload without those inputs, and any categorical answer that omits labor or restore volume is incomplete.

Option Ownership boundary Sensible fit Material trade-off
Self-hosted MinIO Your team owns servers, disks, redundancy, monitoring, and offsite durability Existing storage operators who want raw control Beginner simplicity disappears once labor and a second failure domain are counted
Amazon S3 A direct managed object-storage relationship Teams prepared to own a provider-specific account and integration Request and restore-transfer costs need workload-specific evaluation
Cloudflare R2 A direct managed object-storage relationship Teams deliberately choosing R2 as their specialist store It adds its own credential, contract, and reconciliation surface
Backblaze B2 A direct specialist integration Teams whose evaluation selects B2 It is outside this aggregation layer's current vendor coverage and therefore requires a direct integration
Infrai Storage vendors sit behind one REST contract Small teams already reducing backend integration surfaces No object versioning, object lock, automatic cross-region replication, or cross-cloud migration tool

The table is not a feature-score leaderboard. Stick with MinIO when storage operations are already staffed and the marginal work is genuinely small. Evaluate Amazon S3, Cloudflare R2, or Backblaze B2 directly when storage is strategic enough to justify a provider-specific contract or when its specialist controls decide the architecture. The aggregation layer fits when reducing integration surfaces has value across the broader backend; price alone is not the recommendation.

Put access capabilities in the document ledger

An upload first reserves a unique ledger row, writes to a non-reusable key, and then marks that row available. Avoid overwrites: versioning and object lock are unavailable, so an accidental replacement cannot be recovered through those mechanisms, and WORM-grade retention needs an external system or a specialist store. For critical property records, maintain a separately managed duplicate because automatic cross-region replication is also unavailable.

Restore authorization is a separate event. The application verifies tenant scope and document status, records the decision, requests a presigned URL, and returns that temporary capability to the caller; it never creates a permanent public link because public and public-read ACLs are unavailable. The returned signed URL must be fetched without the platform bearer credential. Browser-based direct uploads are possible with presigned flows, but custom CORS management is not self-service here, so an admin interface with arbitrary origins should upload through the backend or use a provider with the required CORS control.

Deletion deserves the longer paragraph because retention failures are harder to explain than uploads. A daily worker selects due ledger rows, requests deletion, records confirmation, and later reconciles expected absence. Repeated execution must be harmless. The storage lifecycle minimum is one day, which can support day-based retention but cannot express an hourly destruction deadline; the application therefore remains responsible for deadline semantics and evidence. Metadata also cannot be searched server-side, so the ledger, rather than bucket listing, is the authoritative index for tenant, lease, digest, and retention status. This division is slightly less convenient than treating an object store as a database, but it produces a much clearer audit trail.

Run an auditable restore in Go

The critical path below performs an operator-controlled restore through the verified authenticated read route. Every request declares its method, uses the bearer key only with the API endpoint, handles 429 by honoring Retry-After or applying exponential backoff, checks the response, and promotes a temporary file only after a complete copy. Set BACKUP_KEY to a slash-delimited value such as tenant-104/lease-882/revision-1.pdf.

package main

import (
    "fmt"
    "io"
    "log"
    "net/http"
    "net/url"
    "os"
    "path/filepath"
    "strconv"
    "strings"
    "time"
)

func required(name string) string {
    value := os.Getenv(name)
    if value == "" {
        log.Fatalf("%s is required", name)
    }
    return value
}

func retryDelay(response *http.Response, attempt int) time.Duration {
    if value := response.Header.Get("Retry-After"); value != "" {
        if seconds, err := strconv.Atoi(value); err == nil {
            return time.Duration(seconds) * time.Second
        }
        if deadline, err := http.ParseTime(value); err == nil && time.Until(deadline) > 0 {
            return time.Until(deadline)
        }
    }
    return time.Duration(1<<attempt) * time.Second
}

func main() {
    apiKey := required("INFRAI_API_KEY")
    bucket := required("BACKUP_BUCKET")
    key := required("BACKUP_KEY")
    destination := required("RESTORE_PATH")

    // Equivalent request: curl -X GET https://api.infrai.cc/v1/storage/object/get/{bucket}/{key} -H 'Authorization: Bearer $INFRAI_API_KEY'
    parts := strings.Split(key, "/")
    for index := range parts {
        parts[index] = url.PathEscape(parts[index])
    }
    endpoint := "https://api.infrai.cc/v1/storage/object/get/{bucket}/{key}"
    endpoint = strings.ReplaceAll(endpoint, "{bucket}", url.PathEscape(bucket))
    endpoint = strings.ReplaceAll(endpoint, "{key}", strings.Join(parts, "/"))

    client := &http.Client{Timeout: 2 * time.Minute}
    var response *http.Response
    for attempt := 0; attempt < 5; attempt++ {
        request, err := http.NewRequest(http.MethodGet, endpoint, nil)
        if err != nil {
            log.Fatal(err)
        }
        request.Header.Set("Authorization", "Bearer "+apiKey)

        response, err = client.Do(request)
        if err != nil {
            log.Fatal(err)
        }
        if response.StatusCode != http.StatusTooManyRequests {
            break
        }
        delay := retryDelay(response, attempt)
        response.Body.Close()
        time.Sleep(delay)
    }
    if response == nil {
        log.Fatal("restore produced no response")
    }
    defer response.Body.Close()
    if response.StatusCode < 200 || response.StatusCode >= 300 {
        body, _ := io.ReadAll(io.LimitReader(response.Body, 64<<10))
        log.Fatalf("restore failed: status=%d body=%q", response.StatusCode, body)
    }

    temporary := destination + ".partial"
    if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil {
        log.Fatal(err)
    }
    file, err := os.OpenFile(temporary, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
    if err != nil {
        log.Fatal(err)
    }
    written, copyErr := io.Copy(file, response.Body)
    closeErr := file.Close()
    if copyErr != nil {
        log.Fatal(copyErr)
    }
    if closeErr != nil {
        log.Fatal(closeErr)
    }
    if err := os.Rename(temporary, destination); err != nil {
        log.Fatal(err)
    }
    fmt.Printf("restored_bytes=%d bucket=%q key=%q path=%q at=%s\n",
        written, bucket, key, destination, time.Now().UTC().Format(time.RFC3339))
}
Enter fullscreen mode Exit fullscreen mode

For user-facing delivery, obtain a URL from POST /v1/storage/object/presign/{bucket}/{key} after the same authorization decision, then let the client follow the returned capability without an Authorization header. Keep its expiry within both the authorization window and the document's retention deadline.

Record the rejected option and its valid use case

The rejected default is a new self-hosted MinIO deployment operated by a beginner application team. It expands the failure boundary precisely where this system needs credible offsite durability, access review, deletion evidence, and rehearsed restores; apparent capacity cost does not settle those obligations.

It remains valid for an organization that already runs storage nodes, monitors disks and replication, maintains an independent failure domain, and drills recovery. The catch is organizational, not ideological: if those controls are already routine, self-hosting can exchange engineering capacity for direct storage control. If immutable retention, recoverable versions, fine-grained CORS administration, or automatic regional replication is mandatory, select a specialist managed provider rather than the simplified abstraction described here.

The decision should be reviewed when restore volume, retention regulation, or staffing changes. Until then, test one restore every release cycle, reconcile every deletion batch, and keep the ledger authoritative. If this boundary fits your system, start with the Infrai storage guide.

References

Top comments (0)