JB logo

Command Palette

Search for a command to run...

yOUTUBE
Blog
Next

Building Tenant-Safe Data Backups in Go — CSV + SQL Exports, R2 Uploads, and 15-Minute Signed Downloads

A production backup feature in Go: one CSV per table plus an ordered Postgres SQL dump, zipped and pushed to Cloudflare R2, delivered through 15-minute pre-signed URLs. Covers the data model, a schema-agnostic dumpTable built on raw database/sql, tenant scoping, rate limiting, rolling retention, an asynq weekly cron with Postgres advisory-lock leader election, and a React Query polling UI — scaling from a single-tenant Wails desktop app to a multi-tenant SaaS, with the restore path treated as the real deliverable.

Building Tenant-Safe Data Backups in Go

Published July 2026 · By JB (Muke Johnbaptist) — a production pattern for CSV + SQL exports, R2 upload, and 15-minute signed downloads, taken all the way from a single-tenant desktop app to a multi-tenant SaaS with hundreds of businesses.

Stack: Go 1.22 · Gin · GORM · Postgres 15 · Redis · asynq · Cloudflare R2 · React 19 · TanStack Query · Wails 2


Every serious app runs on managed Postgres — RDS, Neon, Supabase — and every one of those platforms takes hourly snapshots you can point-in-time restore from. So why write user-visible backup code at all?

Because "we can restore from RDS" isn't a feature. It's a runbook. The user can't see it, doesn't trust what they can't see, and can't act on it when they want their own copy.

Two distinct audiences make this clear:

  • The end user with an insurance instinct. Someone running a business on your app wants a file they can put on a hard drive, an email, a USB stick. GDPR calls this the right to data portability. But the demand predates the regulation — people just want their stuff.
  • The platform admin who lost a whole database. The AWS-console fat-finger, the botched migration, the intern who ran DELETE FROM sales in the wrong window. A managed snapshot restores the whole cluster. A backup file rebuilds one tenant, one table, one bad decision.

Neither audience is served by the storage layer alone. They both need a file, a button that produces the file, and — critically — a restore path they trust before they need it.

The incident that shipped this feature

Our platform admin accidentally ran a sales wipe on a live tenant. The previous release turned every future wipe into a soft-delete with a 30-day restore. This release gave every tenant admin the download button they should have had from day one. Backups don't just prevent the next incident — they give users the trust they need to keep using your product after one.

Here's how it's built. Whether you're on a single-tenant desktop app or a multi-tenant SaaS with 500 businesses, the same primitives apply. I'll flag the tenant-scoping wherever it matters; if you're single-tenant, skip those bits.


Step 1 — Four decisions before you write code

These four calls shape everything that follows. Making them explicitly beats drifting into them accidentally.

Format: CSV per table plus a Postgres SQL dump

Why not just pg_dump?

  • pg_dump requires the postgres client installed on the box running your Go binary. On a Docker deployment that means a fatter image.
  • Its output is Postgres-only. If your customer wants to inspect it in Excel or import it into MySQL, they can't.

Produce both instead. One CSV per table — universal, opens in any spreadsheet, imports into any database. One dump.sql with Postgres INSERT statements ordered parents-before-children — ready to psql < dump.sql into a fresh, migrated schema. Add a metadata.json manifest with row counts so the tenant can verify integrity before they trust the file.

Storage: object storage, not local disk

Storing backup ZIPs on the app server's disk is a lie. A rolling deploy nukes them. A crashed pod loses them. A 2 GB backup fills the container's writable layer and OOMs the whole box.

Object storage (S3, R2, GCS) fixes all three. We use Cloudflare R2 because egress is free — the customer's download doesn't cost us bandwidth. The code below is portable to S3 with a two-line client swap.

Delivery: signed URL, not file streaming

Streaming a 500 MB file through your Go handler ties up a worker for as long as the download takes. On a slow mobile connection that's minutes.

Signed URLs shift that work to the object store. The Go handler mints a 15-minute pre-signed URL and the customer's browser downloads directly from R2. Fifteen minutes is deliberate: long enough for a slow phone to complete the download, short enough that the URL doesn't survive being accidentally shared or logged.

Rate limit and rolling retention

Two more constraints save you from operational pain:

  • Rate-limit manual backups to one per 24 hours per tenant. Without this, an operator refreshing the page fires N concurrent jobs and R2 costs balloon.
  • Rolling retention keeps the last N backups per tenant. Older ones get deleted from R2 and marked PURGED in the database (audit trail preserved). Storage is bounded regardless of tenant count.

With those calls made, the code writes itself. Here it comes.


Step 2 — The model

Every backup is one row in a tenant_backups table. The row is created immediately in RUNNING state, so a failure mid-generation leaves a visible FAILED record instead of a silent no-op.

// apps/api/internal/models/tenant_backup.go
package models
 
import (
    "time"
    "github.com/google/uuid"
    "gorm.io/gorm"
)
 
type TenantBackup struct {
    ID string `gorm:"primarykey;size:36" json:"id"`
 
    // Scoping. On single-tenant apps, drop this or set it to
    // an installation ID.
    BusinessID string `gorm:"size:36;not null;index" json:"business_id"`
 
    // Kind: WEEKLY_AUTO (cron) | MANUAL (operator) | FULL_DATABASE (admin)
    Kind string `gorm:"size:20;not null;index" json:"kind"`
 
    // Lifecycle: RUNNING → READY / FAILED, then PURGED by retention.
    Status string `gorm:"size:20;not null;index" json:"status"`
 
    GeneratedByUserID *string `gorm:"size:36"`
 
    // R2 key — unique per backup so a concurrent generate can't
    // clobber a fresh file mid-write.
    R2Key     string `gorm:"size:255"`
    SizeBytes int64  `gorm:"default:0"`
    RowCounts string `gorm:"type:text"` // JSON array
 
    StartedAt   time.Time  `gorm:"index"`
    CompletedAt *time.Time
    Error       string `gorm:"type:text"`
 
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:"index"`
}
 
func (t *TenantBackup) BeforeCreate(tx *gorm.DB) error {
    if t.ID == "" {
        t.ID = uuid.New().String()
    }
    if t.StartedAt.IsZero() {
        t.StartedAt = time.Now()
    }
    if t.Status == "" {
        t.Status = "RUNNING"
    }
    return nil
}

Two indices earn their keep: business_id for list-by-tenant lookups on the admin page, and status for the retention query hunting WHERE status = 'READY'.

Single-tenant note. Drop BusinessID entirely. Keep the rest. Use a fixed R2 key prefix (backups/) instead of tenant-backups/<id>/.


Step 3 — The producer

The service has three moving pieces: a fixed list of tables in restore order, a function that dumps one table, and an orchestrator that packs everything into a ZIP and uploads it.

The table list

Order matters. The SQL restore path replays INSERTs top-down; a sale_items row landing before its parent sales row blows up on the foreign key. Keep parents strictly before children.

var backupTables = []string{
    // Configuration / master data — parents first
    "business_categories", "businesses", "branches", "users",
    "units", "product_categories", "accounts",
    "products", "product_variants", "product_lots",
    "customers", "suppliers",
 
    // Stock
    "stock_batches", "stock_movements",
 
    // Transactions — children last
    "purchases", "purchase_items",
    "sales", "sale_items",
 
    // Money
    "account_transactions", "debtor_transactions",
    "creditor_transactions", "expenses",
}

dumpTable — the workhorse

This is where the design pays off. Raw database/sql (not GORM) reads columns and types from the driver dynamically — add a new tenant-owned table to backupTables and it just works, no struct mapping to maintain.

func (s *Service) dumpTable(
    ctx context.Context, table, businessID string,
    csvOut io.Writer, sqlOut io.Writer,
) (int64, error) {
    // Two exceptions in our schema:
    //   business_categories is a global lookup — export all rows.
    //   businesses is the tenant row itself — filter by id.
    var whereClause string
    var args []interface{}
    switch table {
    case "business_categories":
        whereClause = ""
    case "businesses":
        whereClause = " WHERE id = $1"
        args = []interface{}{businessID}
    default:
        whereClause = " WHERE business_id = $1"
        args = []interface{}{businessID}
    }
    // Include soft-deleted rows — the backup is the tenant's
    // insurance file, so keep anything they might want later.
    query := fmt.Sprintf("SELECT * FROM %s%s", table, whereClause)
 
    sqlDB, _ := s.DB.WithContext(ctx).DB()
    rows, err := sqlDB.QueryContext(ctx, query, args...)
    if err != nil {
        return 0, fmt.Errorf("query %s: %w", table, err)
    }
    defer rows.Close()
 
    columns, _ := rows.Columns()
    colTypes, _ := rows.ColumnTypes()
 
    cw := csv.NewWriter(csvOut)
    cw.Write(columns)
    fmt.Fprintf(sqlOut, "\n-- Table: %s\n", table)
 
    var count int64
    for rows.Next() {
        values := make([]interface{}, len(columns))
        ptrs := make([]interface{}, len(columns))
        for i := range values {
            ptrs[i] = &values[i]
        }
        rows.Scan(ptrs...)
 
        cells := make([]string, len(columns))
        for i, v := range values {
            cells[i] = csvFormat(v)
        }
        cw.Write(cells)
 
        lits := make([]string, len(columns))
        for i, v := range values {
            lits[i] = sqlFormat(v, colTypes[i])
        }
        fmt.Fprintf(sqlOut, "INSERT INTO %s (%s) VALUES (%s);\n",
            table,
            strings.Join(columns, ", "),
            strings.Join(lits, ", "),
        )
        count++
    }
    cw.Flush()
    return count, cw.Error()
}

Never accept dynamic table names. The SELECT * above is safe because table names come from a fixed constant, not the request. If you ever accept dynamic names, whitelist strictly — a compromise here reads any table in the database.

Value formatters

Scanned values come back as interface{}. Two formatters shape them for their respective destinations.

// csvFormat renders a scanned value for a CSV cell.
func csvFormat(v interface{}) string {
    switch val := v.(type) {
    case nil:
        return ""
    case []byte:
        return string(val)
    case time.Time:
        return val.UTC().Format(time.RFC3339)
    case bool:
        if val {
            return "true"
        }
        return "false"
    default:
        return fmt.Sprintf("%v", val)
    }
}
 
// sqlFormat renders a Postgres-safe literal for an INSERT.
func sqlFormat(v interface{}, colType *sql.ColumnType) string {
    if v == nil {
        return "NULL"
    }
    switch val := v.(type) {
    case []byte:
        // Postgres JSONB / TEXT / BYTEA all come through as []byte.
        // Distinguish by column type name.
        tn := strings.ToLower(colType.DatabaseTypeName())
        if strings.Contains(tn, "json") {
            return sqlQuote(string(val))
        }
        if strings.Contains(tn, "bytea") {
            return fmt.Sprintf("'\\x%x'::bytea", val)
        }
        return sqlQuote(string(val))
    case string:
        return sqlQuote(val)
    case time.Time:
        return sqlQuote(val.UTC().Format(time.RFC3339))
    case bool:
        if val {
            return "TRUE"
        }
        return "FALSE"
    case int, int64, float64:
        return fmt.Sprintf("%v", val)
    default:
        return sqlQuote(fmt.Sprintf("%v", val))
    }
}
 
func sqlQuote(s string) string {
    return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}

The subtle case is []byte. Postgres drivers return every JSONB, TEXT, and BYTEA column as bytes; the column type name is the only way to know which one you got. Get this wrong and JSONB restore fails with "invalid input syntax for type json."

The orchestrator

Now the pieces stack. An in-memory ZIP writer, a README for the customer, the table walk, and a push to R2. In-memory is simple; for tenants larger than 100 MB, swap to an io.Pipe streaming directly into the R2 upload.

func (s *Service) produceAndUpload(
    ctx context.Context, businessID string,
    biz *models.Business, row *models.TenantBackup,
) (int64, []TableRowCount, error) {
    var buf bytes.Buffer
    zw := zip.NewWriter(&buf)
 
    // README first — plain-text overview so the tenant knows
    // what they're looking at without reading the manifest.
    readme, _ := zw.Create("README.txt")
    fmt.Fprintf(readme, "Business: %s\n", biz.Name)
    fmt.Fprintf(readme, "Generated: %s UTC\n\n",
        row.StartedAt.UTC().Format(time.RFC3339))
    fmt.Fprintf(readme, "Contents:\n")
    fmt.Fprintf(readme, "  csv/<table>.csv   One CSV per table\n")
    fmt.Fprintf(readme, "  dump.sql          Postgres INSERT statements\n")
    fmt.Fprintf(readme, "  metadata.json     Row counts + manifest\n")
 
    sqlBuf := &bytes.Buffer{}
    fmt.Fprintf(sqlBuf, "BEGIN;\n\n")
    counts := make([]TableRowCount, 0, len(backupTables))
 
    for _, table := range backupTables {
        csvBuf := &bytes.Buffer{}
        n, err := s.dumpTable(ctx, table, businessID, csvBuf, sqlBuf)
        if err != nil {
            // Missing table on this deployment isn't fatal.
            counts = append(counts, TableRowCount{
                Table: table, Rows: 0, Error: err.Error(),
            })
            continue
        }
        counts = append(counts, TableRowCount{Table: table, Rows: n})
 
        w, _ := zw.Create(fmt.Sprintf("csv/%s.csv", table))
        io.Copy(w, csvBuf)
    }
 
    fmt.Fprintf(sqlBuf, "COMMIT;\n")
    sqlFile, _ := zw.Create("dump.sql")
    io.Copy(sqlFile, sqlBuf)
 
    metaFile, _ := zw.Create("metadata.json")
    json.NewEncoder(metaFile).Encode(struct {
        BusinessID  string          `json:"business_id"`
        GeneratedAt time.Time       `json:"generated_at"`
        Tables      []TableRowCount `json:"tables"`
    }{biz.ID, row.StartedAt.UTC(), counts})
 
    if err := zw.Close(); err != nil {
        return 0, nil, err
    }
 
    key := fmt.Sprintf("tenant-backups/%s/%s-%s.zip",
        businessID,
        row.StartedAt.UTC().Format("2006-01-02"),
        row.ID[:8],
    )
    row.R2Key = key
    s.DB.Model(row).UpdateColumn("r2_key", key)
 
    if err := s.Storage.Upload(ctx, key,
        bytes.NewReader(buf.Bytes()), "application/zip"); err != nil {
        return 0, nil, fmt.Errorf("upload: %w", err)
    }
    return int64(buf.Len()), counts, nil
}

The Storage abstraction is a thin wrapper around the AWS S3 v2 SDK, which R2 speaks natively:

func (s *Storage) Upload(ctx context.Context, key string,
    r io.Reader, contentType string) error {
    _, err := s.client.PutObject(ctx, &s3.PutObjectInput{
        Bucket: &s.bucketName, Key: &key,
        Body: r, ContentType: &contentType,
    })
    return err
}
 
func (s *Storage) GetSignedURL(ctx context.Context,
    key string, ttl time.Duration) (string, error) {
    presigner := s3.NewPresignClient(s.client)
    req, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
        Bucket: &s.bucketName, Key: &key,
    }, s3.WithPresignExpires(ttl))
    if err != nil {
        return "", err
    }
    return req.URL, nil
}

That's the entire producer. Everything above adds up to about 200 lines of Go.


Step 4 — The HTTP surface

Three routes handle the entire operator flow:

tenantWrite.GET("/backups", h.List)
tenantWrite.POST("/backups/generate", h.Generate)
tenantWrite.GET("/backups/:id/download", h.Download)

tenantWrite is our OWNER-or-MANAGER middleware. Backup files include every customer's PII and the whole ledger — cashier-tier operators shouldn't be able to fire them.

Generate — rate-limited, synchronous

The service enforces the 1-per-24-hours limit before doing any work:

func (s *Service) Generate(ctx context.Context,
    businessID, userID, kind string) (*TenantBackup, error) {
    if s.Storage == nil {
        return nil, ErrStorageUnavailable // → 503
    }
 
    // Rate-limit MANUAL only. Cron-driven WEEKLY_AUTO bypasses.
    if kind == "MANUAL" {
        var recent int64
        since := time.Now().Add(-24 * time.Hour)
        s.DB.Model(&TenantBackup{}).
            Where("business_id = ? AND kind = ? AND started_at >= ? AND status IN ?",
                businessID, "MANUAL", since,
                []string{"READY", "RUNNING"}).
            Count(&recent)
        if recent > 0 {
            return nil, ErrRateLimit // → 429
        }
    }
 
    // Create RUNNING row upfront. On failure it stays visible
    // as FAILED instead of silently no-op'ing.
    row := &TenantBackup{
        BusinessID: businessID, Kind: kind, Status: "RUNNING",
    }
    if userID != "" {
        row.GeneratedByUserID = &userID
    }
    s.DB.Create(row)
 
    size, counts, err := s.produceAndUpload(ctx, businessID, biz, row)
    if err != nil {
        now := time.Now()
        row.Status, row.CompletedAt, row.Error = "FAILED", &now, err.Error()
        s.DB.Save(row)
        return row, err
    }
 
    now := time.Now()
    row.Status, row.CompletedAt, row.SizeBytes = "READY", &now, size
    countsJSON, _ := json.Marshal(counts)
    row.RowCounts = string(countsJSON)
    s.DB.Save(row)
 
    // Rolling cleanup after success. A failed cleanup does NOT
    // fail the backup — the operator already has their file.
    s.rollingCleanup(ctx, businessID)
    return row, nil
}

Download — mint a signed URL, don't stream

func (h *Handler) Download(c *gin.Context) {
    tc, _ := tenant.FromGin(c)
    businessID, _ := tc.MustBusinessID()
 
    var row models.TenantBackup
    if err := h.DB.
        Where("id = ? AND business_id = ?", c.Param("id"), businessID).
        First(&row).Error; err != nil {
        c.JSON(404, gin.H{"error": gin.H{"code": "NOT_FOUND"}})
        return
    }
    if row.Status != "READY" {
        c.JSON(409, gin.H{"error": gin.H{"code": "NOT_READY"}})
        return
    }
 
    url, _ := h.Storage.GetSignedURL(c.Request.Context(),
        row.R2Key, 15*time.Minute)
 
    c.JSON(200, gin.H{"data": gin.H{
        "url":                url,
        "filename":           defaultName(row),
        "expires_in_seconds": 15 * 60,
    }})
}

The WHERE business_id = ? filter is doing security work — a determined caller can't guess another tenant's backup ID and download it. The 15-minute window is aggressive; even if the URL leaks, it doesn't leak for long.


Step 5 — Rolling retention

Rolling retention keeps R2 costs bounded regardless of how enthusiastic your customers get with backups:

func (s *Service) rollingCleanup(ctx context.Context, businessID string) {
    const retain = 4 // Four most recent per tenant.
 
    var stale []models.TenantBackup
    s.DB.
        Where("business_id = ? AND status = ?", businessID, "READY").
        Order("started_at DESC").
        Offset(retain).
        Find(&stale)
 
    for _, b := range stale {
        if b.R2Key == "" {
            continue
        }
        if err := s.Storage.Delete(ctx, b.R2Key); err != nil {
            continue // Best-effort. Retry next tick.
        }
        s.DB.Model(&b).Updates(map[string]interface{}{
            "status": "PURGED",
            "r2_key": "",
        })
    }
}

Notice the row stays in the database as PURGED, not deleted. The audit trail — who ran which backup when — outlives the file. A tenant asking "did we run a backup on the fifth?" gets a clean answer three months later.


Step 6 — The weekly cron, with leader election

For scheduled work we use asynq (Redis-backed job queue) plus a Postgres advisory-lock leader election. Register the schedule:

_, err = scheduler.Register(
    "0 2 * * 0", // Sunday 02:00 UTC
    asynq.NewTask("backup:weekly", nil),
    asynq.Unique(20*time.Hour), // Guards against failover dupes.
)

The Unique(20*time.Hour) window matters. If the leader replica crashes at 01:59 and a follower picks up leadership at 02:01, both would otherwise fire the same task. asynq dedupes.

The handler walks every active non-demo tenant:

func runWeeklyTenantBackup(ctx context.Context,
    db *gorm.DB, store *storage.Storage) {
    if store == nil {
        log.Println("R2 not configured, skipping")
        return
    }
 
    var tenants []models.Business
    db.Where("subscription_status = ? AND is_demo = ?",
        "ACTIVE", false).Find(&tenants)
 
    svc := services.NewTenantBackupService(db, store)
    ok, failed := 0, 0
    for _, biz := range tenants {
        // Timeout per tenant so a stuck upload can't hang the job.
        tenantCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
        row, err := svc.Generate(tenantCtx, biz.ID, "", "WEEKLY_AUTO")
        cancel()
        if err != nil || row.Status != "READY" {
            failed++
            continue
        }
        ok++
    }
    log.Printf("weekly backup: %d ok, %d failed", ok, failed)
}

Three design choices in this handler:

  • Sequential, not parallel. N parallel backups mean N concurrent R2 connections and N table scans. On a busy platform, that spikes CPU on Sunday morning when you're not around. Sequential is slower but predictable.
  • Timeout per tenant. A single hung upload can't wedge the whole job.
  • Silent skip when R2 isn't configured. Dev environments rarely have R2 credentials; this way go run doesn't die on a Sunday.

Leader election lives in Scheduler.Start(). Session-scoped advisory locks release automatically when the connection dies — if the leader crashes, a follower wins the next election within seconds.

var got bool
conn.QueryRowContext(ctx,
    "SELECT pg_try_advisory_lock($1)", cronLockID,
).Scan(&got)
if !got {
    log.Println("Another instance is leader; standing by")
    return nil
}
// This replica owns the lock — run the scheduler.
go func() { s.scheduler.Run() }()

Step 7 — The desktop UI

The operator experience makes or breaks a "click, wait, download" feature. React Query plus a small polling condition gets it right without websockets or SSE.

export function useTenantBackups() {
  return useQuery<TenantBackup[]>({
    queryKey: ["tenant-backups"],
    queryFn: async () => (await apiClient.get("/backups")).data.data,
    // Poll every 3s while any row is RUNNING. Otherwise idle.
    refetchInterval: (query) => {
      const rows = query.state.data as TenantBackup[] | undefined;
      return rows?.some((r) => r.status === "RUNNING") ? 3000 : false;
    },
    staleTime: 15_000,
  });
}

The Generate button fires the mutation; the page re-renders with a RUNNING row; the poll kicks in; when the row flips to READY the poll stops and the Download button appears. Stale-while-revalidate is doing the heavy lifting.

Download itself is a one-liner — hand the browser the signed URL and let it do what browsers do:

async function onDownload(backupID: string) {
  const { url } = await downloadURL.mutateAsync({ backupID });
  window.location.assign(url); // Browser handles the R2 download.
}

Step 8 — Adapting the pattern

Single-tenant apps

Drop the BusinessID column and the WHERE business_id = ? filter. Everything else works. Use a fixed R2 key prefix (backups/) instead of tenant-backups/<id>/. If you have a device or installation identifier, use that as the R2 subfolder to keep multi-device installs separated.

Full-database dump (platform disaster recovery)

Same service, Kind = "FULL_DATABASE", no tenant filter:

func (s *Service) dumpTableFullDB(ctx context.Context,
    table string, csvOut, sqlOut io.Writer) (int64, error) {
    query := fmt.Sprintf("SELECT * FROM %s", table) // No WHERE.
    // ... rest identical to dumpTable.
}

Rate-limit this to one concurrent run to prevent two admins producing competing dumps. Store under a different R2 prefix (platform-backups/) so it doesn't mix with tenant backups.

The restore path is the real test

The backup is only half the feature. The restore path — how someone actually uses the file — needs to be documented and, ideally, tested.

For our dump.sql: run the app's migration on a fresh Postgres to create the schema, then psql <db> < dump.sql. For a single table: psql -c "COPY <table> FROM 'sales.csv' CSV HEADER". For a MySQL customer: their DBA opens the CSVs in whatever tool they use.

None of this is glamorous. All of it matters more than the backup itself. Backups are easy to build. Restores are easy to skip.

Test it before you ship it. Then let the customer test it. Then trust it.


Summary

About 400 lines of Go, 200 lines of React, one cron entry, and the discipline to think about the restore path before the backup path. Ship it, sleep better, and let your customers do the same.

Files in this walkthrough:

  • apps/api/internal/models/tenant_backup.go — the model
  • apps/api/internal/services/tenant_backup_service.go — producer + uploader
  • apps/api/internal/handlers/tenant_backup_handler.go — HTTP surface
  • apps/api/internal/jobs/weekly_backup.go — cron handler
  • apps/api/internal/cron/cron.go — schedule registration + leader election
  • apps/desktop/frontend/src/hooks/use-tenant.ts — React Query hooks
  • apps/desktop/frontend/src/routes/_app/data-backups.tsx — the UI

Companion release: Shoppleet v1.6.140.


Shoppleet is a multi-tenant business management platform written in Go, React, and Wails. This post walks through the backup pattern shipped in v1.6.140.