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 salesin 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_dumprequires 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
PURGEDin 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
BusinessIDentirely. Keep the rest. Use a fixed R2 key prefix (backups/) instead oftenant-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 rundoesn'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 modelapps/api/internal/services/tenant_backup_service.go— producer + uploaderapps/api/internal/handlers/tenant_backup_handler.go— HTTP surfaceapps/api/internal/jobs/weekly_backup.go— cron handlerapps/api/internal/cron/cron.go— schedule registration + leader electionapps/desktop/frontend/src/hooks/use-tenant.ts— React Query hooksapps/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.


