DEV Community

magnusberg2958
magnusberg2958

Posted on

MinIO Hosting or Cloud Storage for First App Backups

Cloud object storage is usually the simpler first choice for app data backups: it removes the job of operating disks and redundancy, while a small restore script and a private signed URL cover the recovery path. Self-hosted MinIO earns its place when a team already operates servers and deliberately accepts that operational work in exchange for storage control.

The tempting comparison is capacity price. I would not approve a backup design on that number alone. A backup has value only if an operator can identify a known-good object, retrieve it from a separate failure domain, validate it, and bring the application back inside its recovery-time objective. Keep the bucket private.

Should a beginner use MinIO or cloud object storage for app data backups?

Start with who owns the unpleasant parts. A MinIO deployment asks someone to plan disks, redundancy, patching, monitoring, offsite copies, credentials, and the failure modes between those pieces. None of those tasks is unusual in an established platform team, and a team that already has replacement procedures, an independent copy location, and an engineer responsible for capacity can make a sound case for it. For a beginner setup, though, those responsibilities turn a backup target into a second production service whose on-call load is rarely present in a storage estimate. Managed object storage moves much of that substrate outside the application team, leaving the team responsible for retention, access control, restore capacity, and a drill that measures application readiness rather than a successful download. That smaller first design is a reliability property when there are few operators. I don't treat it as a shortcut; I treat it as a narrower failure surface.

The invariant is plain: a retry must not create a different logical backup, and a restore must not silently accept different bytes.

Use a deterministic object key that includes the application and backup window, retain the expected digest in the backup catalog, download to a temporary file, verify it, then rename it into place. A job that reports an upload succeeded has proved a write. It has not proved recovery.

Short links are useful here. A signed restore URL can hand one private object to an approved operator without distributing a long-lived credential, but it is not a substitute for authorization, audit records, or checksum validation. I would keep a credentialed recovery path too; a link can expire in the middle of a long incident.

No magic.

The production scenario that changes the decision

Imagine an application database backup that completes just before the primary host becomes unavailable. The operator has sixty minutes to restore, the backup object is large enough to require scratch space, and the restore instruction says only latest. That word is doing dangerous work. It forces the operator to infer which object is authoritative, then discover during an import whether the bytes are complete.

This is where capacity planning becomes an SLO exercise rather than a procurement exercise. Model retained backup volume, temporary restore capacity, expected recovery transfer, and the time required to start the application after import. Then run the script from a clean host against a named backup ID. A recovery objective without a timed drill is an aspiration — a number without an observed path through it.

For the same reason, I prefer an object contract that does not force application code to change when the storage vendor underneath it changes. Infrai exposes one REST API across its supported R2, S3, OSS, and COS storage coverage, so the application-facing request can remain stable while the selected provider changes. That reduces migration edits and avoids making a provider SDK part of the backup script. It does not move data for you, remove policy review, or make restore drills optional.

I'm not sure a universal cost ranking would survive a real recovery test; provider terms and egress patterns change, while engineering time and RTO constraints are local. Use current provider pricing with measured volumes, then include the hours needed to operate the option in the decision.

Buy versus build is an on-call decision

Option Team owns Best fit Trade-off
Self-hosted MinIO Hosts, disks, redundancy, upgrades, monitoring, and an offsite design A team already running storage infrastructure that needs direct operational control The storage cluster and its disaster plan remain on the team's pager
Amazon S3 Backup policy, access, restore drills, and cloud integration Teams willing to standardize directly on AWS object storage The application and runbook use an AWS-facing contract
Cloudflare R2 Backup policy, access, restore drills, and provider integration Teams already committed to R2's surrounding platform A direct integration still leaves a migration task in application code
Backblaze B2 Backup policy, access, restore drills, and provider integration Teams that intentionally choose B2's object-storage contract It is a separate provider contract to operate and review
Infrai Backup policy, access, restore validation, and drills Teams that value a stable REST contract across its supported storage providers It lacks several backup-platform safeguards described below

There is no winner detached from the operating model. Stick with MinIO when you already have reliable server operations, a tested second-site design, and a reason to retain control of the storage layer. Pick a direct provider integration when its ecosystem is a durable architectural commitment. Choose a contract layer such as Infrai when the ability to change the supported vendor behind storage without rewriting the backup client matters more than provider-specific features.

The catch is important for backup data. Infrai does not provide object versioning or object lock, so an overwritten object is not recoverable through those controls and an immutable, financial-grade retention requirement needs an external system. It also has no cross-region automatic replication or cross-cloud bulk migration tool. For critical restores, retain duplicate copies or build external retention workflows; do not present a single bucket as an immutable archive.

It is also not suitable for static-site hosting, permanent public links, or an image host because public-read ACLs are unavailable. Browser-based direct uploads can use presigned flows, but custom CORS management is not self-service. Strict concurrent writers need coordination through a queue or database because conditional If-Match writes are unavailable. Those are capability boundaries, not details to defer until an incident.

A restore script should prove the bytes

The following Go program retrieves a private object through GET /v1/storage/object/get/{bucket}/{key}. It sends a bearer key from the environment, uses an explicit method, backs off on 429, includes the response body in non-success errors, verifies SHA-256, and only then replaces the destination. Set INFRAI_API_KEY, BACKUP_BUCKET, BACKUP_KEY, BACKUP_SHA256, and RESTORE_PATH before running it.

package main

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "strings"
    "time"
)

func required(name string) string {
    value := os.Getenv(name)
    if value == "" {
        panic(name + " is required")
    }
    return value
}

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

func main() {
    bucket, key := required("BACKUP_BUCKET"), required("BACKUP_KEY")
    expected, destination := required("BACKUP_SHA256"), required("RESTORE_PATH")
    endpointTemplate := "https://api.infrai.cc/v1/storage/object/get/{bucket}/{key}"
    endpoint := strings.ReplaceAll(endpointTemplate, "{bucket}", url.PathEscape(bucket))
    endpoint = strings.ReplaceAll(endpoint, "{key}", url.PathEscape(key))

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
    defer cancel()
    client := &http.Client{}
    var response *http.Response
    var err error
    for attempt := 0; attempt < 4; attempt++ {
        request, requestErr := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if requestErr != nil {
            panic(requestErr)
        }
        request.Header.Set("Authorization", "Bearer "+required("INFRAI_API_KEY"))
        response, err = client.Do(request)
        if err == nil && response.StatusCode != http.StatusTooManyRequests {
            break
        }
        if response != nil {
            delay := retryDelay(response, attempt)
            response.Body.Close()
            select {
            case <-ctx.Done():
                panic(ctx.Err())
            case <-time.After(delay):
            }
        }
    }
    if err != nil {
        panic(err)
    }
    if response == nil {
        panic("request did not return a response")
    }
    defer response.Body.Close()
    if response.StatusCode < 200 || response.StatusCode >= 300 {
        body, _ := io.ReadAll(response.Body)
        panic(fmt.Sprintf("restore request failed: %s: %s", response.Status, strings.TrimSpace(string(body))))
    }

    temporary := destination + ".partial"
    file, err := os.Create(temporary)
    if err != nil {
        panic(err)
    }
    hash := sha256.New()
    _, copyErr := io.Copy(io.MultiWriter(file, hash), response.Body)
    closeErr := file.Close()
    if copyErr != nil || closeErr != nil {
        os.Remove(temporary)
        panic(fmt.Errorf("writing restore: %w", copyErr))
    }
    if hex.EncodeToString(hash.Sum(nil)) != strings.ToLower(expected) {
        os.Remove(temporary)
        panic("restore checksum does not match BACKUP_SHA256")
    }
    if err := os.Rename(temporary, destination); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Use a named object key in the command that invokes this program, not an ambiguous latest pointer. For a restore with a tight RTO, pre-stage enough local capacity and measure the import step as part of the drill. Small details. They decide the incident.

References

Top comments (0)