diff --git a/.github/workflows/ci-main.yml b/.github/workflows/ci-main.yml
index a29e422be..c08c1a49f 100644
--- a/.github/workflows/ci-main.yml
+++ b/.github/workflows/ci-main.yml
@@ -119,7 +119,7 @@ jobs:
   # ===========================================================================
   build:
     runs-on: ubuntu-latest
-    needs: [go-tests, frontend-tests]
+    needs: [go-tests, frontend-tests, backup-integration]
     permissions:
       contents: read
       packages: write
diff --git a/AGENTS.md b/AGENTS.md
index d94dfdd49..00697b83b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -93,6 +93,7 @@ Notable `methods/` groupings:
 - Resource CRUD: `distributors.go`, `resellers.go`, `customers.go`, `users.go`, `systems.go`, `applications.go`
 - Bulk import/export: `*_import.go`, `*_export.go`, `import_helpers.go`
 - Alerting: `alerting.go` (backend-side — per-tenant config, active alerts from Mimir, history from DB)
+- Backups: `backups.go` (list/download/delete of appliance configuration backups stored on S3; purges the system's prefix on hard delete for GDPR)
 - Filters: `systems_filters.go`, `users_filters.go` — aggregation endpoints for UI filters
 - Other: `auth.go`, `impersonate.go`, `rebranding.go`, `inventory.go`, `organizations.go`, `roles.go`, `totals.go`, `validators/`
 
@@ -118,6 +119,7 @@ Key properties:
 - Systems auth with HTTP Basic (`system_key:system_secret`, SHA256 in DB).
 - `/api/services/mimir/alertmanager/api/v2/{alerts,silences}[/*subpath]` proxied to Mimir with server-set `X-Scope-OrgID` and authoritative identity labels (`injectLabels` overwrites client values, strips when DB is NULL).
 - `/api/alert_history` receives Alertmanager resolved-alert webhooks with Bearer auth (constant-time compare, fail-closed). `organization_id` is resolved at write-time from `systems.system_key`; unknown keys are dropped.
+- `/api/systems/backups` ingests GPG-encrypted configuration backups from appliances. Stream body → S3 with SHA-256 `io.TeeReader`, metadata reconciled via same-key `CopyObject`, retention enforced inline under a Redis `SET NX` lock, per-system rate limit. Keys: `{org_id}/{system_key}/{backup_id}.{ext}`. Storage is any S3-compatible bucket configured via `BACKUP_S3_*` env vars (see `collect/README.md`).
 
 ### 3.3 Sync (`sync/`)
 
@@ -348,6 +350,14 @@ make build-all    # linux/darwin/windows × amd64/arm64
 4. **Update `backend/openapi.yaml`** (mandatory — validated by `make validate-docs`).
 5. `make pre-commit`.
 
+### 10.1b Naming — plural vs singular in path segments
+
+- **Plural** for collections with N elements addressable by ID: `/users`, `/systems`, `/alerts`, `/alerts/silences`, `/backups`. Any endpoint that supports `GET` list, `GET /:id`, `POST` create, or `DELETE /:id` belongs here.
+- **Singular** for singletons (one resource per parent) and for command/snapshot endpoints that accept a single payload and don't expose a collection: `/alerts/config`, `/me`, `/inventory`, `/heartbeat`.
+- Aggregate queries over a collection stay plural: `/alerts/totals`, `/alerts/trend`, `/systems/totals`.
+
+Apparent asymmetry like `/systems/:id/backups` (plural) next to `/systems/inventory` (singular) reflects this rule — `backups` is a multi-item collection, `inventory` is a single snapshot the client pushes and the server replaces.
+
 ### 10.2 API reference
 
 Authoritative: `backend/openapi.yaml` (also `make docs` / redocly). High-level route groups in `backend/main.go`:
diff --git a/backend/.env.example b/backend/.env.example
index 5b8a2a3b6..759327dd6 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -78,6 +78,30 @@ REDIS_URL=redis://localhost:6379
 # System configuration
 #SYSTEM_TYPES=ns8,nsec
 
+# Backup storage (DigitalOcean Spaces — shared S3 account with Mimir)
+# Endpoint, access key, and secret key are the same values configured for
+# Mimir (MIMIR_S3_*). Only the bucket differs — a dedicated bucket for
+# appliance configuration backups. Backend consumes the bucket read-only
+# to list backups and generate short-lived presigned download URLs.
+#BACKUP_S3_ENDPOINT=https://ams3.digitaloceanspaces.com
+# Region value used in SigV4 signing. For DO Spaces use the region code
+# (ams3, nyc3, fra1, …). For AWS S3 use the target region (eu-west-1, …).
+# For most emulators (MinIO, Garage) any non-empty value works; the code
+# falls back to "us-east-1" when unset.
+#BACKUP_S3_REGION=ams3
+#BACKUP_S3_BUCKET=my-backups-dev
+#BACKUP_S3_ACCESS_KEY=
+#BACKUP_S3_SECRET_KEY=
+#BACKUP_S3_USE_PATH_STYLE=false
+# Alternate endpoint used only when signing presigned URLs. Leave unset
+# in production. In local dev, set BACKUP_S3_ENDPOINT to the container-
+# internal hostname (e.g. http://minio-local:9000) and this var to the
+# host-visible one (e.g. http://localhost:19000) so the browser can
+# follow the download URL.
+#BACKUP_S3_PRESIGN_ENDPOINT=
+# How long a presigned download URL remains valid.
+#BACKUP_PRESIGN_TTL=5m
+
 # ===========================================
 # MIMIR CONFIGURATION (Optional)
 # ===========================================
@@ -89,7 +113,10 @@ REDIS_URL=redis://localhost:6379
 
 # Shared secret for alert history webhook authentication (Bearer token).
 # Set the same value in collect/.env or in deployment configuration as
-# ALERTING_HISTORY_WEBHOOK_SECRET
+# ALERTING_HISTORY_WEBHOOK_SECRET. Must be at least 32 characters; the
+# secret is rendered into the Alertmanager YAML pushed to Mimir, so a
+# short value can be cheaply brute-forced from any read of the Mimir
+# alertmanager-config bucket.
 #ALERTING_HISTORY_WEBHOOK_SECRET=
 
 
diff --git a/backend/.redocly.lint-ignore.yaml b/backend/.redocly.lint-ignore.yaml
index 1c489dbf1..2660178bf 100644
--- a/backend/.redocly.lint-ignore.yaml
+++ b/backend/.redocly.lint-ignore.yaml
@@ -3,3 +3,4 @@
 openapi.yaml:
   no-ambiguous-paths:
     - '#/paths/~1systems~1rebranding~1{product_id}~1{asset}'
+    - '#/paths/~1systems~1backups~1{id}'
diff --git a/backend/.render-build-trigger b/backend/.render-build-trigger
index 4ee864ad3..320733ced 100644
--- a/backend/.render-build-trigger
+++ b/backend/.render-build-trigger
@@ -2,9 +2,9 @@
 # This file is used to force Docker service rebuilds in PR previews
 # Modify LAST_UPDATE to trigger rebuilds
 
-LAST_UPDATE=2026-04-07T13:40:58Z
+LAST_UPDATE=2026-04-22T07:15:29Z
 
 # Instructions:
 # 1. To force rebuild of Docker services in a PR, update LAST_UPDATE
-# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-04-07T13:40:58Z/LAST_UPDATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)/" $f/.render-build-trigger
+# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-04-22T07:15:29Z
 # 2. Commit and push changes to trigger Docker rebuilds
diff --git a/backend/README.md b/backend/README.md
index 928582b5b..734d7f3de 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -55,6 +55,30 @@ DATABASE_URL=postgresql://noc_user:noc_password@localhost:5432/noc?sslmode=disab
 # Redis connection URL
 REDIS_URL=redis://localhost:6379
 
+# ===========================================
+# BACKUP STORAGE (S3-compatible)
+# ===========================================
+# Any S3-compatible endpoint works: DigitalOcean Spaces, AWS S3,
+# Cloudflare R2, Backblaze B2, self-hosted MinIO / Garage, etc.
+# Both backend and collect must point at the SAME bucket; backend
+# reads and issues presigned URLs, collect writes uploads from
+# appliances. See collect/README.md for the bucket layout.
+BACKUP_S3_ENDPOINT=https://ams3.digitaloceanspaces.com
+BACKUP_S3_REGION=ams3
+BACKUP_S3_BUCKET=my-backups
+BACKUP_S3_ACCESS_KEY=your-access-key
+BACKUP_S3_SECRET_KEY=your-secret-key
+# Set to "true" only against local emulators that do not serve
+# virtual-hosted-style URLs. Spaces / S3 / R2 use the default (false).
+BACKUP_S3_USE_PATH_STYLE=false
+# Optional: override the endpoint used for signing download URLs so the
+# browser can follow them when backend and the S3 emulator are reached
+# via different hostnames (compose network vs. host ports). Empty in
+# production.
+#BACKUP_S3_PRESIGN_ENDPOINT=
+# Lifetime of a presigned download URL (capped server-side at 15m).
+#BACKUP_PRESIGN_TTL=5m
+
 # ===========================================
 # SMTP EMAIL CONFIGURATION (Optional)
 # ===========================================
diff --git a/backend/cache/backup_quota.go b/backend/cache/backup_quota.go
new file mode 100644
index 000000000..136516b3f
--- /dev/null
+++ b/backend/cache/backup_quota.go
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2026 Nethesis S.r.l.
+ * http://www.nethesis.it - info@nethesis.it
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * author: Edoardo Spadoni <edoardo.spadoni@nethesis.it>
+ */
+
+package cache
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/nethesis/my/backend/logger"
+)
+
+// InvalidateOrgBackupUsage drops the cached aggregate-usage counter for the
+// given organization on the shared Redis DB. collect maintains the counter
+// incrementally on its own writes; backend mutates the same prefix when an
+// admin deletes a backup, when DestroySystem purges, or when a cross-org
+// reassignment moves bytes around — none of which collect's counter sees.
+// Invalidating the key forces collect's next quota check to recompute from
+// S3, restoring consistency.
+//
+// The counter lives on the SHARED Redis DB (configured via REDIS_DB_SHARED,
+// default 1, matching collect's default REDIS_DB) so this DEL must also use
+// that DB; backend's own client (DB 0 by default) cannot reach it.
+//
+// The cache key shape MUST match collect/methods/backups.go::orgUsageCacheKey.
+// Best-effort: a Redis hiccup is logged but does not block the caller.
+func InvalidateOrgBackupUsage(ctx context.Context, orgID string) {
+	if orgID == "" {
+		return
+	}
+	client := GetSharedRedisClient()
+	if client == nil {
+		return
+	}
+	key := fmt.Sprintf("backup:org_usage:%s", orgID)
+	if err := client.Del(ctx, key).Err(); err != nil {
+		logger.Warn().Err(err).Str("org_id", orgID).Msg("failed to invalidate org backup usage counter")
+	}
+}
diff --git a/backend/cache/locks.go b/backend/cache/locks.go
new file mode 100644
index 000000000..b1b6448df
--- /dev/null
+++ b/backend/cache/locks.go
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2026 Nethesis S.r.l.
+ * http://www.nethesis.it - info@nethesis.it
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * author: Edoardo Spadoni <edoardo.spadoni@nethesis.it>
+ */
+
+package cache
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"errors"
+	"time"
+
+	"github.com/nethesis/my/backend/logger"
+)
+
+// ErrSystemReassignLocked is returned by AcquireSystemReassignLock when
+// another concurrent reassignment for the same system holds the lock.
+var ErrSystemReassignLocked = errors.New("system reassignment is already in progress")
+
+const systemReassignLockKeyPrefix = "lock:system:reassign:"
+
+// AcquireSystemReassignLock takes a Redis SETNX-based advisory lock on the
+// system_id for the duration of an org reassignment. The lock has a TTL so a
+// crashed process cannot wedge a system forever; the TTL is set generously
+// (default 5 m) above the realistic ceiling of the migration loop, which
+// typically runs in single-digit seconds.
+//
+// Returns a release function that the caller MUST defer; it deletes the key
+// only if the value still matches the random fence token written here, so a
+// release after the TTL expired (and another holder may have taken the lock)
+// does not free the wrong holder.
+//
+// If Redis is unreachable the lock fails open: a logged warning, no lock
+// taken, the caller proceeds. This matches the established pattern for
+// non-critical cache infrastructure (the worst case under Redis outage is
+// the same as before this lock existed) and avoids tying a normal admin
+// operation to Redis availability.
+func AcquireSystemReassignLock(ctx context.Context, systemID string, ttl time.Duration) (func(), error) {
+	rc := GetRedisClient()
+	if rc == nil || rc.client == nil {
+		logger.Warn().Str("system_id", systemID).Msg("Redis unavailable, reassignment lock disabled")
+		return func() {}, nil
+	}
+
+	tokenBytes := make([]byte, 16)
+	if _, err := rand.Read(tokenBytes); err != nil {
+		return nil, err
+	}
+	token := hex.EncodeToString(tokenBytes)
+	key := systemReassignLockKeyPrefix + systemID
+
+	ok, err := rc.client.SetNX(ctx, key, token, ttl).Result()
+	if err != nil {
+		// Treat Redis errors as fail-open: log and let the caller proceed.
+		logger.Warn().Err(err).Str("system_id", systemID).Msg("reassignment lock SETNX failed; proceeding without lock")
+		return func() {}, nil
+	}
+	if !ok {
+		return nil, ErrSystemReassignLocked
+	}
+
+	release := func() {
+		// Compare-and-delete via a small Lua script: only release the lock
+		// when its value still matches our token, so an expired-then-retaken
+		// lock is not freed by us.
+		const releaseScript = `
+			if redis.call("GET", KEYS[1]) == ARGV[1] then
+				return redis.call("DEL", KEYS[1])
+			else
+				return 0
+			end`
+		ctxRelease, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+		if err := rc.client.Eval(ctxRelease, releaseScript, []string{key}, token).Err(); err != nil {
+			logger.Warn().Err(err).Str("system_id", systemID).Msg("reassignment lock release failed")
+		}
+	}
+	return release, nil
+}
diff --git a/backend/cache/redis.go b/backend/cache/redis.go
index d58b6798e..8fbf1b758 100644
--- a/backend/cache/redis.go
+++ b/backend/cache/redis.go
@@ -40,6 +40,10 @@ type RedisClient struct {
 var (
 	redisClient *RedisClient
 	once        sync.Once
+
+	sharedRedisClient *redis.Client
+	sharedOnce        sync.Once
+	sharedErr         error
 )
 
 // redisConfig holds Redis connection configuration
@@ -148,6 +152,47 @@ func GetRedisClient() *RedisClient {
 	return redisClient
 }
 
+// GetSharedRedisClient returns a Redis client connected to the cross-service
+// database (REDIS_DB_SHARED, default 1, matching collect's default REDIS_DB).
+// Used for keys mutated by both backend and collect — currently the per-org
+// backup-usage counter (`backup:org_usage:{org}`). Backend's primary client
+// (DB 0 by default) cannot DEL keys that collect wrote on its own DB.
+//
+// Lazy-initialised on first call; safe for concurrent use. Returns nil on
+// connection failure so callers can fall back to a no-op.
+func GetSharedRedisClient() *redis.Client {
+	sharedOnce.Do(func() {
+		opts, err := redis.ParseURL(configuration.Config.RedisURL)
+		if err != nil {
+			sharedErr = fmt.Errorf("failed to parse Redis URL: %w", err)
+			return
+		}
+		opts.DB = configuration.Config.RedisDBShared
+		if configuration.Config.RedisPassword != "" {
+			opts.Password = configuration.Config.RedisPassword
+		}
+		opts.PoolSize = 4
+		opts.MinIdleConns = 1
+		opts.ConnMaxIdleTime = 5 * time.Minute
+		opts.ConnMaxLifetime = 30 * time.Minute
+
+		client := redis.NewClient(opts)
+		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+		defer cancel()
+		if _, err := client.Ping(ctx).Result(); err != nil {
+			_ = client.Close()
+			sharedErr = fmt.Errorf("ping shared Redis (DB %d): %w", opts.DB, err)
+			return
+		}
+		sharedRedisClient = client
+		log.Info().Str("component", "redis-shared").Int("db", opts.DB).Msg("shared Redis client initialised")
+	})
+	if sharedErr != nil {
+		return nil
+	}
+	return sharedRedisClient
+}
+
 // IsRedisAvailable checks if Redis client is available and connected
 func IsRedisAvailable() bool {
 	if redisClient == nil {
diff --git a/backend/cache/system_auth.go b/backend/cache/system_auth.go
new file mode 100644
index 000000000..85c34de55
--- /dev/null
+++ b/backend/cache/system_auth.go
@@ -0,0 +1,77 @@
+/*
+ * Copyright (C) 2026 Nethesis S.r.l.
+ * http://www.nethesis.it - info@nethesis.it
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * author: Edoardo Spadoni <edoardo.spadoni@nethesis.it>
+ */
+
+package cache
+
+import (
+	"context"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+
+	"github.com/nethesis/my/backend/configuration"
+	"github.com/nethesis/my/backend/logger"
+)
+
+// systemAuthInvalidationChannelBase is the namespace prefix for the cross-service
+// Redis pub/sub channel backend uses to signal collect that a system_key's
+// credentials have been revoked or rotated. The actual channel name is suffixed
+// with APP_ENV ("dev"/"qa"/"prod") so a Redis instance shared across deployments
+// stops cross-pollinating invalidations.
+const systemAuthInvalidationChannelBase = "my:auth:invalidate"
+
+// SystemAuthInvalidationChannel returns the env-namespaced channel name. Exported
+// so tests and the consumer subscribe to the same string deterministically.
+func SystemAuthInvalidationChannel() string {
+	env := configuration.Config.AppEnv
+	if env == "" {
+		env = "dev"
+	}
+	return systemAuthInvalidationChannelBase + ":" + env
+}
+
+// signInvalidationPayload returns the wire payload sent on the channel.
+// When INTERNAL_HMAC_SECRET is set the payload is `<systemKey>|<hex(hmac)>` so
+// a network-adjacent attacker with PUBLISH access cannot forge invalidations.
+// When the secret is empty (dev) the bare system_key is sent and the consumer
+// accepts it on a best-effort basis.
+func signInvalidationPayload(systemKey string) string {
+	secret := configuration.Config.InternalHMACSecret
+	if secret == "" {
+		return systemKey
+	}
+	mac := hmac.New(sha256.New, []byte(secret))
+	mac.Write([]byte(systemKey))
+	return systemKey + "|" + hex.EncodeToString(mac.Sum(nil))
+}
+
+// InvalidateSystemAuth publishes a system_key on the global auth
+// invalidation channel so collect can purge its in-memory and Redis-cached
+// credentials for that system immediately, instead of waiting up to the
+// full SystemAuthCacheTTL window.
+//
+// Best effort: a Redis outage is logged but does not block the caller —
+// the cached entries still expire naturally within SystemAuthCacheTTL.
+func InvalidateSystemAuth(ctx context.Context, systemKey string) {
+	if systemKey == "" {
+		return
+	}
+	rc := GetRedisClient()
+	if rc == nil || rc.client == nil {
+		logger.Warn().Str("system_key", systemKey).Msg("Redis unavailable, skipping auth cache invalidation")
+		return
+	}
+	channel := SystemAuthInvalidationChannel()
+	payload := signInvalidationPayload(systemKey)
+	if err := rc.client.Publish(ctx, channel, payload).Err(); err != nil {
+		logger.Warn().Err(err).Str("system_key", systemKey).Msg("failed to publish auth invalidation")
+		return
+	}
+	logger.Debug().Str("system_key", systemKey).Str("channel", channel).Msg("auth cache invalidation published")
+}
diff --git a/backend/configuration/configuration.go b/backend/configuration/configuration.go
index 381638f75..6c4c38258 100644
--- a/backend/configuration/configuration.go
+++ b/backend/configuration/configuration.go
@@ -11,6 +11,7 @@ package configuration
 
 import (
 	"fmt"
+	"net/url"
 	"os"
 	"strconv"
 	"strings"
@@ -39,8 +40,12 @@ type Configuration struct {
 	LogtoManagementClientSecret string `json:"logto_management_client_secret"`
 	LogtoManagementBaseURL      string `json:"logto_management_base_url"`
 	// Redis configuration
-	RedisURL          string        `json:"redis_url"`
-	RedisDB           int           `json:"redis_db"`
+	RedisURL string `json:"redis_url"`
+	RedisDB  int    `json:"redis_db"`
+	// RedisDBShared is the Redis logical DB used for cross-service keys
+	// (currently the per-org backup-usage counter). Default 1 to match
+	// collect's default REDIS_DB.
+	RedisDBShared     int           `json:"redis_db_shared"`
 	RedisPassword     string        `json:"redis_password"`
 	RedisMaxRetries   int           `json:"redis_max_retries"`
 	RedisDialTimeout  time.Duration `json:"redis_dial_timeout"`
@@ -72,6 +77,31 @@ type Configuration struct {
 	// Alerting configuration
 	AlertingHistoryWebhookURL    string `json:"alerting_history_webhook_url"`
 	AlertingHistoryWebhookSecret string `json:"alerting_history_webhook_secret"`
+
+	// Cross-service plumbing.
+	// AppEnv namespaces internal pub/sub channels (auth invalidation, etc.)
+	// so a Redis instance shared between dev/qa/prod stops cross-pollinating.
+	// Default "dev"; canonical values: dev, qa, prod.
+	AppEnv string `json:"app_env"`
+	// InternalHMACSecret signs internal pub/sub payloads so a network-adjacent
+	// attacker with PUBLISH access cannot forge invalidations. Optional in dev
+	// (empty disables the verification on the consumer side); required for
+	// production-grade isolation.
+	InternalHMACSecret string `json:"internal_hmac_secret"`
+
+	// Backup storage — S3 client credentials used to read from the
+	// DigitalOcean Spaces bucket that holds client configuration
+	// backups. The same Spaces account also hosts the Mimir buckets;
+	// values for endpoint, access key, and secret key are the shared
+	// S3 credentials.
+	BackupS3Endpoint        string        `json:"backup_s3_endpoint"`
+	BackupS3PresignEndpoint string        `json:"backup_s3_presign_endpoint"`
+	BackupS3Region          string        `json:"backup_s3_region"`
+	BackupS3Bucket          string        `json:"backup_s3_bucket"`
+	BackupS3AccessKey       string        `json:"backup_s3_access_key"`
+	BackupS3SecretKey       string        `json:"backup_s3_secret_key"`
+	BackupS3UsePathStyle    bool          `json:"backup_s3_use_path_style"`
+	BackupPresignTTL        time.Duration `json:"backup_presign_ttl"`
 }
 
 var Config = Configuration{}
@@ -173,6 +203,7 @@ func Init() {
 	}
 
 	Config.RedisDB = parseIntWithDefault("REDIS_DB", 0)
+	Config.RedisDBShared = parseIntWithDefault("REDIS_DB_SHARED", 1)
 	Config.RedisPassword = os.Getenv("REDIS_PASSWORD")
 	Config.RedisMaxRetries = parseIntWithDefault("REDIS_MAX_RETRIES", 3)
 	Config.RedisDialTimeout = parseDurationWithDefault("REDIS_DIAL_TIMEOUT", 5*time.Second)
@@ -217,14 +248,113 @@ func Init() {
 		logger.LogConfigLoad("env", "MIMIR_URL", true, fmt.Errorf("MIMIR_URL variable is empty, using default http://localhost:9009"))
 	}
 
-	// Alerting configuration — optional, empty means no built-in history webhook
+	// Alerting configuration — optional, empty means no built-in history webhook.
+	// Backend renders the secret into the Alertmanager YAML pushed to Mimir, so
+	// a short value would be cheaply brute-forced from any read of the Mimir
+	// alertmanager-config bucket. Refuse to boot with a too-short secret.
 	Config.AlertingHistoryWebhookURL = os.Getenv("ALERTING_HISTORY_WEBHOOK_URL")
 	Config.AlertingHistoryWebhookSecret = os.Getenv("ALERTING_HISTORY_WEBHOOK_SECRET")
+	if Config.AlertingHistoryWebhookSecret != "" && len(Config.AlertingHistoryWebhookSecret) < 32 {
+		logger.Fatal().
+			Int("length", len(Config.AlertingHistoryWebhookSecret)).
+			Msg("ALERTING_HISTORY_WEBHOOK_SECRET must be at least 32 characters; refusing to start with a weak secret")
+	}
+
+	// Internal cross-service plumbing. APP_ENV scopes pub/sub channel names so
+	// dev/qa/prod sharing a Redis instance never invalidate each other's caches.
+	// INTERNAL_HMAC_SECRET, when set, signs the system_key payload on the
+	// auth-invalidation channel; collect drops messages with a missing or
+	// invalid HMAC. A short secret is refused (< 32 chars).
+	if v := os.Getenv("APP_ENV"); v != "" {
+		Config.AppEnv = v
+	} else {
+		Config.AppEnv = "dev"
+	}
+	Config.InternalHMACSecret = os.Getenv("INTERNAL_HMAC_SECRET")
+	if Config.InternalHMACSecret != "" && len(Config.InternalHMACSecret) < 32 {
+		logger.Fatal().
+			Int("length", len(Config.InternalHMACSecret)).
+			Msg("INTERNAL_HMAC_SECRET must be at least 32 characters; refusing to start with a weak secret")
+	}
+
+	// Backup storage — S3 client credentials (DigitalOcean Spaces)
+	Config.BackupS3Endpoint = validateBackupEndpoint("BACKUP_S3_ENDPOINT", os.Getenv("BACKUP_S3_ENDPOINT"))
+	// Optional override used only when the API-facing endpoint differs
+	// from the endpoint the user's browser can reach (typical for local
+	// dev where backend runs inside a container and MinIO is exposed on
+	// the host). Empty in production.
+	Config.BackupS3PresignEndpoint = validateBackupEndpoint("BACKUP_S3_PRESIGN_ENDPOINT", os.Getenv("BACKUP_S3_PRESIGN_ENDPOINT"))
+	if envRegion := os.Getenv("BACKUP_S3_REGION"); envRegion != "" {
+		Config.BackupS3Region = envRegion
+	} else {
+		Config.BackupS3Region = "us-east-1"
+	}
+	Config.BackupS3Bucket = os.Getenv("BACKUP_S3_BUCKET")
+	Config.BackupS3AccessKey = os.Getenv("BACKUP_S3_ACCESS_KEY")
+	Config.BackupS3SecretKey = os.Getenv("BACKUP_S3_SECRET_KEY")
+	Config.BackupS3UsePathStyle = parseBoolWithDefault("BACKUP_S3_USE_PATH_STYLE", false)
+	// Cap the presigned URL lifetime at 15 minutes so a misconfigured
+	// env can never mint long-lived bearer URLs to backup objects.
+	ttl := parseDurationWithDefault("BACKUP_PRESIGN_TTL", 5*time.Minute)
+	if ttl > 15*time.Minute {
+		logger.LogConfigLoad("env", "BACKUP_PRESIGN_TTL", false, fmt.Errorf("value %s exceeds the 15m hard cap, clamping", ttl))
+		ttl = 15 * time.Minute
+	}
+	if ttl <= 0 {
+		ttl = 5 * time.Minute
+	}
+	Config.BackupPresignTTL = ttl
 
 	// Log successful configuration load
 	logger.LogConfigLoad("env", "configuration", true, nil)
 }
 
+// validateBackupEndpoint refuses HTTP endpoints unless the host is one
+// of the well-known dev loopback names; misconfigured prod deployments
+// would otherwise send signed S3 traffic in plaintext. Empty values are
+// returned unchanged (the storage package surfaces a clearer error).
+func validateBackupEndpoint(name, raw string) string {
+	if raw == "" {
+		return ""
+	}
+	u, err := url.Parse(raw)
+	if err != nil || u.Host == "" {
+		logger.LogConfigLoad("env", name, false, fmt.Errorf("invalid URL %q", raw))
+		return ""
+	}
+	// Reject userinfo (https://attacker:pw@host) — there is no legitimate
+	// reason to embed credentials in a bucket endpoint URL.
+	if u.User != nil {
+		logger.LogConfigLoad("env", name, false, fmt.Errorf("userinfo in endpoint URL is not allowed"))
+		return ""
+	}
+	// The endpoint must be the bucket service root, not a sub-path. The S3
+	// SDK appends `/<bucket>/<key>` to whatever is given, so a non-root path
+	// silently mis-targets traffic.
+	if u.Path != "" && u.Path != "/" {
+		logger.LogConfigLoad("env", name, false, fmt.Errorf("non-root path %q in endpoint URL is not allowed", u.Path))
+		return ""
+	}
+	if u.Scheme == "https" {
+		return raw
+	}
+	if u.Scheme == "http" {
+		host := u.Hostname()
+		// Allowlist exact loopback hosts only. Suffix-matching `.local` /
+		// `.localtest.me` would otherwise admit `evil.local` /
+		// `evil.localtest.me` shipped over plaintext.
+		if host == "localhost" || host == "127.0.0.1" || host == "::1" ||
+			host == "my.localtest.me" ||
+			parseBoolWithDefault("BACKUP_S3_ALLOW_INSECURE", false) {
+			return raw
+		}
+		logger.LogConfigLoad("env", name, false, fmt.Errorf("HTTP endpoint to non-loopback host %q rejected; set BACKUP_S3_ALLOW_INSECURE=true to override", host))
+		return ""
+	}
+	logger.LogConfigLoad("env", name, false, fmt.Errorf("unsupported scheme %q", u.Scheme))
+	return ""
+}
+
 // parseDurationWithDefault parses a duration from environment variable or returns default
 func parseDurationWithDefault(envVar string, defaultValue time.Duration) time.Duration {
 	envValue := os.Getenv(envVar)
diff --git a/backend/database/migrations/023_idx_inventory_records_created_at.sql b/backend/database/migrations/020_idx_inventory_records_created_at.sql
similarity index 86%
rename from backend/database/migrations/023_idx_inventory_records_created_at.sql
rename to backend/database/migrations/020_idx_inventory_records_created_at.sql
index 1faed231e..2d7df5e7f 100644
--- a/backend/database/migrations/023_idx_inventory_records_created_at.sql
+++ b/backend/database/migrations/020_idx_inventory_records_created_at.sql
@@ -1,4 +1,4 @@
--- Migration 023: Index inventory_records on (system_id, created_at)
+-- Migration 020: Index inventory_records on (system_id, created_at)
 --
 -- Supports the exponential-retention cleanup in
 -- collect/workers/cleanup_worker.go which filters and partitions by
diff --git a/backend/database/migrations/023_idx_inventory_records_created_at_rollback.sql b/backend/database/migrations/020_idx_inventory_records_created_at_rollback.sql
similarity index 57%
rename from backend/database/migrations/023_idx_inventory_records_created_at_rollback.sql
rename to backend/database/migrations/020_idx_inventory_records_created_at_rollback.sql
index 6798e703e..56bb90bcf 100644
--- a/backend/database/migrations/023_idx_inventory_records_created_at_rollback.sql
+++ b/backend/database/migrations/020_idx_inventory_records_created_at_rollback.sql
@@ -1,3 +1,3 @@
--- Rollback migration 023: Drop (system_id, created_at) index on inventory_records
+-- Rollback migration 020: Drop (system_id, created_at) index on inventory_records
 
 DROP INDEX IF EXISTS idx_inventory_records_system_id_created_at;
diff --git a/backend/database/migrations/021_add_system_org_transfers.sql b/backend/database/migrations/021_add_system_org_transfers.sql
new file mode 100644
index 000000000..406bcbdfb
--- /dev/null
+++ b/backend/database/migrations/021_add_system_org_transfers.sql
@@ -0,0 +1,59 @@
+-- Migration 021: Add system_org_transfers audit table
+--
+-- Records every successful cross-organization reassignment of a system. The
+-- row is written synchronously inside UpdateSystem so the audit trail cannot
+-- be silenced by a logging-only failure. Three downstream consumers depend
+-- on it:
+--
+--   1. Forensics: a tamper-evident record of who moved what, when, and to
+--      where — the only artefact left after a hostile insider triggers a
+--      reassignment to exfiltrate backups via the legitimate download API.
+--   2. GDPR purge: DestroySystem iterates every from_org_id this system has
+--      ever been under and runs DeleteBackupPrefix on each, so a partial
+--      cleanup failure during reassignment cannot leak ciphertext past the
+--      destroy call.
+--   3. UI history: future "this system was previously owned by …" surface
+--      for admins.
+
+CREATE TABLE IF NOT EXISTS system_org_transfers (
+    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    system_id     VARCHAR(255) NOT NULL,
+    system_key    VARCHAR(255) NOT NULL,
+
+    -- The org the system left and the one it landed in. Both are Logto IDs;
+    -- both can outlive the orgs themselves (an org can be soft-deleted later)
+    -- so neither is a foreign key.
+    from_org_id   VARCHAR(255) NOT NULL,
+    to_org_id     VARCHAR(255) NOT NULL,
+
+    -- Actor identity. Captured at the moment of the change; stays valid even
+    -- if the user is later deleted. user_id may match a row in users(logto_id).
+    actor_user_id          VARCHAR(255),
+    actor_user_email       VARCHAR(255),
+    actor_organization_id  VARCHAR(255),
+
+    -- Source IP and user agent of the request that triggered the transfer.
+    actor_ip      VARCHAR(64),
+    user_agent    TEXT,
+
+    -- Outcome counters captured at completion. Best-effort steps that fail
+    -- are logged separately; counts here reflect what landed in storage.
+    backups_copied   INTEGER NOT NULL DEFAULT 0,
+    backups_deleted  INTEGER NOT NULL DEFAULT 0,
+    silences_cleared INTEGER NOT NULL DEFAULT 0,
+    history_rows_reassigned INTEGER NOT NULL DEFAULT 0,
+    apps_unassigned  INTEGER NOT NULL DEFAULT 0,
+
+    occurred_at   TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+);
+
+COMMENT ON TABLE system_org_transfers IS 'Append-only audit log of cross-organization system reassignments';
+COMMENT ON COLUMN system_org_transfers.from_org_id IS 'Logto organization ID the system was moved from';
+COMMENT ON COLUMN system_org_transfers.to_org_id IS 'Logto organization ID the system was moved to';
+
+CREATE INDEX IF NOT EXISTS idx_system_org_transfers_system_id_occurred_at
+  ON system_org_transfers(system_id, occurred_at DESC);
+CREATE INDEX IF NOT EXISTS idx_system_org_transfers_from_org_id
+  ON system_org_transfers(from_org_id);
+CREATE INDEX IF NOT EXISTS idx_system_org_transfers_to_org_id
+  ON system_org_transfers(to_org_id);
diff --git a/backend/database/migrations/021_add_system_org_transfers_rollback.sql b/backend/database/migrations/021_add_system_org_transfers_rollback.sql
new file mode 100644
index 000000000..58d751769
--- /dev/null
+++ b/backend/database/migrations/021_add_system_org_transfers_rollback.sql
@@ -0,0 +1,2 @@
+-- Rollback migration 021
+DROP TABLE IF EXISTS system_org_transfers;
diff --git a/backend/database/schema.sql b/backend/database/schema.sql
index 386204387..9bc2d1589 100644
--- a/backend/database/schema.sql
+++ b/backend/database/schema.sql
@@ -756,6 +756,40 @@ CREATE INDEX IF NOT EXISTS idx_alert_history_system_key_created_at ON alert_hist
 CREATE INDEX IF NOT EXISTS idx_alert_history_starts_at ON alert_history(starts_at DESC);
 CREATE INDEX IF NOT EXISTS idx_alert_history_org_id_created_at ON alert_history(organization_id, created_at DESC);
 
+-- =============================================================================
+-- SYSTEM ORG TRANSFERS (audit log, append-only)
+-- =============================================================================
+-- Append-only audit log of cross-organization system reassignments. See
+-- migration 021 for the full rationale and column documentation.
+
+CREATE TABLE IF NOT EXISTS system_org_transfers (
+    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    system_id     VARCHAR(255) NOT NULL,
+    system_key    VARCHAR(255) NOT NULL,
+    from_org_id   VARCHAR(255) NOT NULL,
+    to_org_id     VARCHAR(255) NOT NULL,
+    actor_user_id          VARCHAR(255),
+    actor_user_email       VARCHAR(255),
+    actor_organization_id  VARCHAR(255),
+    actor_ip      VARCHAR(64),
+    user_agent    TEXT,
+    backups_copied   INTEGER NOT NULL DEFAULT 0,
+    backups_deleted  INTEGER NOT NULL DEFAULT 0,
+    silences_cleared INTEGER NOT NULL DEFAULT 0,
+    history_rows_reassigned INTEGER NOT NULL DEFAULT 0,
+    apps_unassigned  INTEGER NOT NULL DEFAULT 0,
+    occurred_at   TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
+);
+
+COMMENT ON TABLE system_org_transfers IS 'Append-only audit log of cross-organization system reassignments';
+
+CREATE INDEX IF NOT EXISTS idx_system_org_transfers_system_id_occurred_at
+  ON system_org_transfers(system_id, occurred_at DESC);
+CREATE INDEX IF NOT EXISTS idx_system_org_transfers_from_org_id
+  ON system_org_transfers(from_org_id);
+CREATE INDEX IF NOT EXISTS idx_system_org_transfers_to_org_id
+  ON system_org_transfers(to_org_id);
+
 -- =============================================================================
 -- SCHEMA MIGRATIONS TABLE
 -- =============================================================================
diff --git a/backend/entities/local_alertmanager_history.go b/backend/entities/local_alertmanager_history.go
index 48cac6aec..afa874076 100644
--- a/backend/entities/local_alertmanager_history.go
+++ b/backend/entities/local_alertmanager_history.go
@@ -251,3 +251,28 @@ func (r *LocalAlertHistoryRepository) GetAlertHistoryTrend(period int, orgID str
 		DataPoints:      dataPoints,
 	}, nil
 }
+
+// ReassignSystemAlertHistory rewrites the denormalized organization_id on every
+// alert_history row that belongs to the given system_key and was previously
+// scoped to fromOrgID. Used by the org-reassignment flow so the new owner can
+// see the system's full history while the donor org loses access along with
+// the system. Returns the number of rows updated; idempotent (a second call
+// matches no rows because organization_id has already moved).
+func (r *LocalAlertHistoryRepository) ReassignSystemAlertHistory(systemKey, fromOrgID, toOrgID string) (int64, error) {
+	if systemKey == "" || fromOrgID == "" || toOrgID == "" {
+		return 0, fmt.Errorf("systemKey, fromOrgID and toOrgID are required")
+	}
+	if fromOrgID == toOrgID {
+		return 0, nil
+	}
+	result, err := r.db.Exec(
+		`UPDATE alert_history
+		 SET organization_id = $1
+		 WHERE system_key = $2 AND organization_id = $3`,
+		toOrgID, systemKey, fromOrgID,
+	)
+	if err != nil {
+		return 0, fmt.Errorf("failed to reassign alert history: %w", err)
+	}
+	return result.RowsAffected()
+}
diff --git a/backend/entities/local_alertmanager_history_test.go b/backend/entities/local_alertmanager_history_test.go
index ee4977ca0..97403aadb 100644
--- a/backend/entities/local_alertmanager_history_test.go
+++ b/backend/entities/local_alertmanager_history_test.go
@@ -261,3 +261,59 @@ func TestGetAlertHistoryTrend_Down(t *testing.T) {
 	assert.Len(t, trend.DataPoints, 30)
 	assert.NoError(t, mock.ExpectationsWereMet())
 }
+
+func TestReassignSystemAlertHistory(t *testing.T) {
+	repo, mock, cleanup := setupAlertHistoryMock(t)
+	defer cleanup()
+
+	mock.ExpectExec(`UPDATE alert_history\s+SET organization_id = \$1\s+WHERE system_key = \$2 AND organization_id = \$3`).
+		WithArgs("org-new", "SYS-001", "org-old").
+		WillReturnResult(sqlmock.NewResult(0, 7))
+
+	rows, err := repo.ReassignSystemAlertHistory("SYS-001", "org-old", "org-new")
+	require.NoError(t, err)
+	assert.Equal(t, int64(7), rows)
+	assert.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestReassignSystemAlertHistoryNoopWhenSameOrg(t *testing.T) {
+	repo, _, cleanup := setupAlertHistoryMock(t)
+	defer cleanup()
+
+	// Same source and destination: must not hit the database at all
+	// (any unexpected Exec would be flagged by sqlmock on close).
+	rows, err := repo.ReassignSystemAlertHistory("SYS-001", "org-1", "org-1")
+	require.NoError(t, err)
+	assert.Equal(t, int64(0), rows)
+}
+
+func TestReassignSystemAlertHistoryRejectsEmptyArgs(t *testing.T) {
+	repo, _, cleanup := setupAlertHistoryMock(t)
+	defer cleanup()
+
+	cases := []struct{ key, from, to string }{
+		{"", "org-old", "org-new"},
+		{"SYS-001", "", "org-new"},
+		{"SYS-001", "org-old", ""},
+	}
+	for _, tc := range cases {
+		_, err := repo.ReassignSystemAlertHistory(tc.key, tc.from, tc.to)
+		assert.Error(t, err)
+	}
+}
+
+func TestReassignSystemAlertHistoryReturnsZeroWhenNoMatch(t *testing.T) {
+	repo, mock, cleanup := setupAlertHistoryMock(t)
+	defer cleanup()
+
+	// Idempotency: a re-run on a system whose history has already moved
+	// matches no rows but is still a successful no-op.
+	mock.ExpectExec(`UPDATE alert_history`).
+		WithArgs("org-new", "SYS-001", "org-old").
+		WillReturnResult(sqlmock.NewResult(0, 0))
+
+	rows, err := repo.ReassignSystemAlertHistory("SYS-001", "org-old", "org-new")
+	require.NoError(t, err)
+	assert.Equal(t, int64(0), rows)
+	assert.NoError(t, mock.ExpectationsWereMet())
+}
diff --git a/backend/entities/local_applications.go b/backend/entities/local_applications.go
index f451b9b0e..dfa28cae1 100644
--- a/backend/entities/local_applications.go
+++ b/backend/entities/local_applications.go
@@ -889,6 +889,35 @@ func (r *LocalApplicationRepository) UnassignOrganization(id string) error {
 	return nil
 }
 
+// UnassignAllForSystem clears the organization assignment on every application
+// that belongs to the given system. Invoked when the system itself is moved to
+// a different organization: the previous owner's RBAC scope is gone, and the
+// new owner may sit in a different hierarchy where the previously chosen
+// org_id has no meaning. The new owner reassigns each app explicitly.
+//
+// Returns the number of applications reset; idempotent (a rerun matches no
+// rows because everything is already unassigned).
+func (r *LocalApplicationRepository) UnassignAllForSystem(systemID string) (int64, error) {
+	if systemID == "" {
+		return 0, fmt.Errorf("systemID is required")
+	}
+	result, err := r.db.Exec(
+		`UPDATE applications
+		 SET organization_id = NULL,
+		     organization_type = NULL,
+		     status = 'unassigned',
+		     updated_at = $2
+		 WHERE system_id = $1
+		   AND deleted_at IS NULL
+		   AND organization_id IS NOT NULL`,
+		systemID, time.Now(),
+	)
+	if err != nil {
+		return 0, fmt.Errorf("failed to unassign applications for system: %w", err)
+	}
+	return result.RowsAffected()
+}
+
 // Delete soft-deletes an application
 func (r *LocalApplicationRepository) Delete(id string) error {
 	query := `
diff --git a/backend/entities/local_applications_test.go b/backend/entities/local_applications_test.go
new file mode 100644
index 000000000..3e76b080e
--- /dev/null
+++ b/backend/entities/local_applications_test.go
@@ -0,0 +1,68 @@
+/*
+Copyright (C) 2026 Nethesis S.r.l.
+SPDX-License-Identifier: AGPL-3.0-or-later
+*/
+
+package entities
+
+import (
+	"testing"
+
+	"github.com/DATA-DOG/go-sqlmock"
+	"github.com/nethesis/my/backend/database"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func setupAppRepoMock(t *testing.T) (*LocalApplicationRepository, sqlmock.Sqlmock, func()) {
+	originalDB := database.DB
+	mockDB, mock, err := sqlmock.New()
+	require.NoError(t, err)
+
+	database.DB = mockDB
+	repo := NewLocalApplicationRepository()
+
+	cleanup := func() {
+		database.DB = originalDB
+		_ = mockDB.Close()
+	}
+	return repo, mock, cleanup
+}
+
+func TestUnassignAllForSystem(t *testing.T) {
+	repo, mock, cleanup := setupAppRepoMock(t)
+	defer cleanup()
+
+	mock.ExpectExec(`UPDATE applications\s+SET organization_id = NULL,\s+organization_type = NULL,\s+status = 'unassigned',\s+updated_at = \$2\s+WHERE system_id = \$1\s+AND deleted_at IS NULL\s+AND organization_id IS NOT NULL`).
+		WithArgs("sys-1", sqlmock.AnyArg()).
+		WillReturnResult(sqlmock.NewResult(0, 3))
+
+	rows, err := repo.UnassignAllForSystem("sys-1")
+	require.NoError(t, err)
+	assert.Equal(t, int64(3), rows)
+	assert.NoError(t, mock.ExpectationsWereMet())
+}
+
+func TestUnassignAllForSystemRejectsEmpty(t *testing.T) {
+	repo, _, cleanup := setupAppRepoMock(t)
+	defer cleanup()
+
+	_, err := repo.UnassignAllForSystem("")
+	assert.Error(t, err)
+}
+
+func TestUnassignAllForSystemReturnsZeroWhenNothingAssigned(t *testing.T) {
+	repo, mock, cleanup := setupAppRepoMock(t)
+	defer cleanup()
+
+	// Idempotency: rerun on a system whose apps are already unassigned
+	// produces zero matches but no error.
+	mock.ExpectExec(`UPDATE applications`).
+		WithArgs("sys-1", sqlmock.AnyArg()).
+		WillReturnResult(sqlmock.NewResult(0, 0))
+
+	rows, err := repo.UnassignAllForSystem("sys-1")
+	require.NoError(t, err)
+	assert.Equal(t, int64(0), rows)
+	assert.NoError(t, mock.ExpectationsWereMet())
+}
diff --git a/backend/entities/local_system_org_transfers.go b/backend/entities/local_system_org_transfers.go
new file mode 100644
index 000000000..9f800a6c7
--- /dev/null
+++ b/backend/entities/local_system_org_transfers.go
@@ -0,0 +1,112 @@
+/*
+Copyright (C) 2026 Nethesis S.r.l.
+SPDX-License-Identifier: AGPL-3.0-or-later
+*/
+
+package entities
+
+import (
+	"database/sql"
+	"fmt"
+	"time"
+
+	"github.com/nethesis/my/backend/database"
+)
+
+// SystemOrgTransfer mirrors a row in system_org_transfers. Counter columns are
+// captured at the end of the migration so a single row tells the whole story
+// of what landed where.
+type SystemOrgTransfer struct {
+	ID                    string
+	SystemID              string
+	SystemKey             string
+	FromOrgID             string
+	ToOrgID               string
+	ActorUserID           sql.NullString
+	ActorUserEmail        sql.NullString
+	ActorOrganizationID   sql.NullString
+	ActorIP               sql.NullString
+	UserAgent             sql.NullString
+	BackupsCopied         int
+	BackupsDeleted        int
+	SilencesCleared       int
+	HistoryRowsReassigned int
+	AppsUnassigned        int
+	OccurredAt            time.Time
+}
+
+// LocalSystemOrgTransfersRepository writes and reads the audit table for
+// cross-organization system reassignments. Reads exist for forensics and for
+// the GDPR purge path that iterates every prior org of a system on destroy.
+type LocalSystemOrgTransfersRepository struct {
+	db *sql.DB
+}
+
+func NewLocalSystemOrgTransfersRepository() *LocalSystemOrgTransfersRepository {
+	return &LocalSystemOrgTransfersRepository{db: database.DB}
+}
+
+// Insert appends a single transfer row. Returns the generated id.
+func (r *LocalSystemOrgTransfersRepository) Insert(t *SystemOrgTransfer) (string, error) {
+	if t == nil {
+		return "", fmt.Errorf("transfer is required")
+	}
+	if t.SystemID == "" || t.SystemKey == "" || t.FromOrgID == "" || t.ToOrgID == "" {
+		return "", fmt.Errorf("system_id, system_key, from_org_id and to_org_id are required")
+	}
+	var id string
+	err := r.db.QueryRow(
+		`INSERT INTO system_org_transfers
+		    (system_id, system_key, from_org_id, to_org_id,
+		     actor_user_id, actor_user_email, actor_organization_id,
+		     actor_ip, user_agent,
+		     backups_copied, backups_deleted, silences_cleared,
+		     history_rows_reassigned, apps_unassigned)
+		 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
+		 RETURNING id`,
+		t.SystemID, t.SystemKey, t.FromOrgID, t.ToOrgID,
+		t.ActorUserID, t.ActorUserEmail, t.ActorOrganizationID,
+		t.ActorIP, t.UserAgent,
+		t.BackupsCopied, t.BackupsDeleted, t.SilencesCleared,
+		t.HistoryRowsReassigned, t.AppsUnassigned,
+	).Scan(&id)
+	if err != nil {
+		return "", fmt.Errorf("insert system_org_transfers: %w", err)
+	}
+	return id, nil
+}
+
+// PriorOrgIDsForSystem returns the distinct from_org_id values where the
+// system used to live, ordered most-recent first. DestroySystem uses this to
+// run DeleteBackupPrefix on every historical prefix in addition to the
+// current one — a partial cleanup failure during reassignment cannot leak
+// ciphertext past the destroy.
+func (r *LocalSystemOrgTransfersRepository) PriorOrgIDsForSystem(systemID string) ([]string, error) {
+	if systemID == "" {
+		return nil, fmt.Errorf("systemID is required")
+	}
+	rows, err := r.db.Query(
+		`SELECT DISTINCT from_org_id
+		 FROM system_org_transfers
+		 WHERE system_id = $1
+		 ORDER BY from_org_id`,
+		systemID,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("query system_org_transfers: %w", err)
+	}
+	defer func() { _ = rows.Close() }()
+
+	out := []string{}
+	for rows.Next() {
+		var orgID string
+		if err := rows.Scan(&orgID); err != nil {
+			return nil, fmt.Errorf("scan from_org_id: %w", err)
+		}
+		out = append(out, orgID)
+	}
+	if err := rows.Err(); err != nil {
+		return nil, err
+	}
+	return out, nil
+}
diff --git a/backend/entities/local_systems.go b/backend/entities/local_systems.go
index fc0e65eb4..2a90ea769 100644
--- a/backend/entities/local_systems.go
+++ b/backend/entities/local_systems.go
@@ -42,6 +42,21 @@ func (r *LocalSystemRepository) Create(req *models.CreateSystemRequest) (*models
 
 // GetByID retrieves a specific system by ID without access validation (validation is done at service level)
 func (r *LocalSystemRepository) GetByID(id string) (*models.System, error) {
+	return r.getByID(id, false)
+}
+
+// GetByIDIncludingDeleted is like GetByID but also returns soft-deleted rows.
+// Destructive flows that still need (org_id, system_key) after a soft delete
+// (GDPR-compliant backup purge before hard destroy) go through this method.
+func (r *LocalSystemRepository) GetByIDIncludingDeleted(id string) (*models.System, error) {
+	return r.getByID(id, true)
+}
+
+func (r *LocalSystemRepository) getByID(id string, includeDeleted bool) (*models.System, error) {
+	whereClause := "WHERE s.id = $1 AND s.deleted_at IS NULL"
+	if includeDeleted {
+		whereClause = "WHERE s.id = $1"
+	}
 	query := `
 		SELECT s.id, s.name, s.type, s.status, s.fqdn, s.ipv4_address, s.ipv6_address, s.version,
 		       s.system_key, s.organization_id, s.custom_data, s.notes, s.created_at, s.updated_at, s.created_by, s.registered_at, s.suspended_at, s.suspended_by_org_id, h.last_heartbeat, s.last_inventory_at,
@@ -51,8 +66,7 @@ func (r *LocalSystemRepository) GetByID(id string) (*models.System, error) {
 		FROM systems s
 		LEFT JOIN system_heartbeats h ON s.id = h.system_id
 		LEFT JOIN unified_organizations uo ON s.organization_id = uo.logto_id
-		WHERE s.id = $1 AND s.deleted_at IS NULL
-	`
+		` + whereClause
 
 	system := &models.System{}
 	var customDataJSON []byte
@@ -159,12 +173,15 @@ func (r *LocalSystemRepository) ListByCreatedByOrganizations(allowedOrgIDs []str
 	var whereClause string
 	var args []interface{}
 	if allowedOrgIDs != nil {
-		// Use ANY($1::text[]) instead of individual placeholders for efficiency
+		// Use ANY($1::text[]) instead of individual placeholders for efficiency.
+		// Filter on the current owning organization (systems.organization_id),
+		// not on the creator org (created_by ->> 'organization_id'): a
+		// reassigned system must follow the new owner's RBAC scope.
 		args = []interface{}{pq.Array(allowedOrgIDs)}
 		if hasDeletedFilter {
-			whereClause = "s.created_by ->> 'organization_id' = ANY($1::text[])"
+			whereClause = "s.organization_id = ANY($1::text[])"
 		} else {
-			whereClause = "s.deleted_at IS NULL AND s.created_by ->> 'organization_id' = ANY($1::text[])"
+			whereClause = "s.deleted_at IS NULL AND s.organization_id = ANY($1::text[])"
 		}
 	} else {
 		// Owner: no org filter needed
diff --git a/backend/go.mod b/backend/go.mod
index de21c9fa5..02ca8606f 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -4,6 +4,10 @@ go 1.25.0
 
 require (
 	github.com/DATA-DOG/go-sqlmock v1.5.2
+	github.com/aws/aws-sdk-go-v2 v1.41.5
+	github.com/aws/aws-sdk-go-v2/config v1.32.14
+	github.com/aws/aws-sdk-go-v2/credentials v1.19.14
+	github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
 	github.com/gin-contrib/cors v1.7.7
 	github.com/gin-contrib/gzip v1.2.6
 	github.com/gin-gonic/gin v1.12.0
@@ -18,9 +22,25 @@ require (
 	github.com/stretchr/testify v1.11.1
 	golang.org/x/image v0.38.0
 	golang.org/x/text v0.35.0
+	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
+	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
+	github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
+	github.com/aws/smithy-go v1.24.2 // indirect
 	github.com/boombuler/barcode v1.0.1 // indirect
 	github.com/bytedance/gopkg v0.1.3 // indirect
 	github.com/bytedance/sonic v1.15.0 // indirect
@@ -65,5 +85,4 @@ require (
 	golang.org/x/sys v0.42.0 // indirect
 	google.golang.org/protobuf v1.36.10 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/backend/go.sum b/backend/go.sum
index 798c3473d..a7d6a11b1 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -1,5 +1,43 @@
 github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
 github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
+github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
+github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
+github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
+github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
+github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
+github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
 github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
 github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
 github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -25,8 +63,6 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/f-amaral/go-async v0.3.0 h1:h4kLsX7aKfdWaHvV0lf+/EE3OIeCzyeDYJDb/vDZUyg=
 github.com/f-amaral/go-async v0.3.0/go.mod h1:Hz5Qr6DAWpbTTUjytnrg1WIsDgS7NtOei5y8SipYS7U=
-github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
-github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
 github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
 github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
 github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
@@ -43,8 +79,6 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
-github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
 github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
 github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -146,8 +180,6 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
 go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
 golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
 golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
-golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
 golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
 golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
 golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -158,8 +190,6 @@ golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
 golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/backend/helpers/errors.go b/backend/helpers/errors.go
index 6d13f8079..9736dc20e 100644
--- a/backend/helpers/errors.go
+++ b/backend/helpers/errors.go
@@ -15,8 +15,15 @@ import (
 	"github.com/nethesis/my/backend/response"
 )
 
-// HandleAccessError handles common entity access errors (not found, access denied, generic).
-// Returns true if the error was handled and the caller should return.
+// HandleAccessError handles common entity access errors (not found, access
+// denied, generic). Returns true if the error was handled and the caller
+// should return.
+//
+// Status codes are kept distinct (404 / 403 / 500) so the UI can produce
+// accurate user-facing messages, but the response bodies no longer echo
+// the input entity_id or the raw internal error text — those would let an
+// authenticated caller use the API as a hierarchy oracle or pull internal
+// diagnostics. The discriminator is preserved in the server log.
 func HandleAccessError(c *gin.Context, err error, entityType, entityID string) bool {
 	if err == nil {
 		return false
@@ -30,14 +37,10 @@ func HandleAccessError(c *gin.Context, err error, entityType, entityID string) b
 	}
 
 	if strings.Contains(errMsg, "access denied") {
-		c.JSON(http.StatusForbidden, response.Forbidden("access denied to "+entityType, map[string]interface{}{
-			entityType + "_id": entityID,
-		}))
+		c.JSON(http.StatusForbidden, response.Forbidden("access denied to "+entityType, nil))
 		return true
 	}
 
-	c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to process "+entityType+" request", map[string]interface{}{
-		"error": errMsg,
-	}))
+	c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to process "+entityType+" request", nil))
 	return true
 }
diff --git a/backend/helpers/errors_test.go b/backend/helpers/errors_test.go
index 60588e4c9..33a8b3121 100644
--- a/backend/helpers/errors_test.go
+++ b/backend/helpers/errors_test.go
@@ -60,10 +60,8 @@ func TestHandleAccessError_AccessDenied(t *testing.T) {
 	err := json.Unmarshal(w.Body.Bytes(), &resp)
 	assert.NoError(t, err)
 	assert.Equal(t, "access denied to system", resp["message"])
-
-	data, ok := resp["data"].(map[string]interface{})
-	assert.True(t, ok)
-	assert.Equal(t, "sys-123", data["system_id"])
+	// Entity ID must not be echoed back so the body itself isn't an oracle.
+	assert.Nil(t, resp["data"])
 }
 
 func TestHandleAccessError_GenericError(t *testing.T) {
@@ -79,10 +77,8 @@ func TestHandleAccessError_GenericError(t *testing.T) {
 	err := json.Unmarshal(w.Body.Bytes(), &resp)
 	assert.NoError(t, err)
 	assert.Equal(t, "failed to process application request", resp["message"])
-
-	data, ok := resp["data"].(map[string]interface{})
-	assert.True(t, ok)
-	assert.Equal(t, "database connection failed", data["error"])
+	// Internal error message must not leak to the client either.
+	assert.Nil(t, resp["data"])
 }
 
 func TestHandleAccessError_DifferentEntityTypes(t *testing.T) {
diff --git a/backend/main.go b/backend/main.go
index 584dd3d32..c95616421 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -260,6 +260,12 @@ func main() {
 			systemsGroup.PUT("/:id/alerts/silences/:silence_id", methods.UpdateSystemAlertSilence)    // Update a silence's end time / comment
 			systemsGroup.DELETE("/:id/alerts/silences/:silence_id", methods.DeleteSystemAlertSilence) // Disable a silence for a system alert
 			systemsGroup.GET("/:id/alerts/history", methods.GetSystemAlertHistory)                    // Get paginated alert history
+
+			// Backup endpoints — list stored backups, issue short-lived presigned
+			// download URLs, and delete backups. Ingest happens on collect.
+			systemsGroup.GET("/:id/backups", methods.GetSystemBackups)                         // List backups for a system
+			systemsGroup.GET("/:id/backups/:backup_id/download", methods.DownloadSystemBackup) // 302 to presigned URL
+			systemsGroup.DELETE("/:id/backups/:backup_id", methods.DeleteSystemBackup)         // Delete a backup
 		}
 
 		// ===========================================
diff --git a/backend/methods/backups.go b/backend/methods/backups.go
new file mode 100644
index 000000000..826d1791e
--- /dev/null
+++ b/backend/methods/backups.go
@@ -0,0 +1,369 @@
+/*
+Copyright (C) 2026 Nethesis S.r.l.
+SPDX-License-Identifier: AGPL-3.0-or-later
+*/
+
+package methods
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"regexp"
+	"sort"
+	"strings"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/service/s3"
+	s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+	"github.com/gin-gonic/gin"
+
+	"github.com/nethesis/my/backend/cache"
+	"github.com/nethesis/my/backend/configuration"
+	"github.com/nethesis/my/backend/helpers"
+	"github.com/nethesis/my/backend/logger"
+	"github.com/nethesis/my/backend/response"
+	"github.com/nethesis/my/backend/services/local"
+	"github.com/nethesis/my/backend/storage"
+)
+
+// backupIDPattern pins the shape of a valid backup identifier: a UUIDv7
+// plus one of the extensions produced by the ingest side. Anything else —
+// path components, traversal tokens, URL-encoded slashes, unexpected
+// suffixes — is refused before it reaches the storage layer so the S3
+// key cannot be diverted outside the system's prefix.
+var backupIDPattern = regexp.MustCompile(
+	`^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(?:\.(?:tar\.gz|tar\.xz|tar\.bz2|tar\.zst|gpg|bin))?$`,
+)
+
+func isValidBackupID(id string) bool {
+	return backupIDPattern.MatchString(strings.ToLower(id))
+}
+
+// BackupMetadata is the JSON payload returned in list responses for a single
+// backup object. The uploader IP is intentionally not exposed: on traffic
+// that transits the translation proxy the stored value is the proxy's IP
+// (not the appliance's), and where it is accurate it would still be a
+// reconnaissance aid for any admin above the customer tier.
+type BackupMetadata struct {
+	ID         string    `json:"id"`
+	Filename   string    `json:"filename"`
+	Size       int64     `json:"size"`
+	SHA256     string    `json:"sha256"`
+	MimeType   string    `json:"mimetype"`
+	UploadedAt time.Time `json:"uploaded_at"`
+}
+
+// BackupListResponse wraps the list of backups with aggregate usage counters
+// (useful for rendering quota/slot indicators in the UI).
+type BackupListResponse struct {
+	Backups        []BackupMetadata `json:"backups"`
+	QuotaUsedBytes int64            `json:"quota_used_bytes"`
+	SlotsUsed      int              `json:"slots_used"`
+}
+
+// GetSystemBackups handles GET /api/systems/:id/backups — returns the list of
+// backups stored for the given system, enriched with size, checksum, and
+// uploader metadata. Access is gated by the same RBAC rules as GetSystem.
+func GetSystemBackups(c *gin.Context) {
+	systemID := c.Param("id")
+	if systemID == "" {
+		c.JSON(http.StatusBadRequest, response.BadRequest("system ID required", nil))
+		return
+	}
+
+	user, ok := helpers.GetUserFromContext(c)
+	if !ok {
+		return
+	}
+
+	systemsService := local.NewSystemsService()
+	system, err := systemsService.GetSystem(systemID, user.OrgRole, user.OrganizationID)
+	if helpers.HandleAccessError(c, err, "system", systemID) {
+		return
+	}
+
+	ctx := c.Request.Context()
+	client, _, err := storage.BackupClient(ctx)
+	if err != nil {
+		logger.Error().Err(err).Msg("backup storage client unavailable")
+		c.JSON(http.StatusServiceUnavailable, response.Error(http.StatusServiceUnavailable, "backup storage unavailable", nil))
+		return
+	}
+
+	items, used, err := listSystemBackups(ctx, client, system.Organization.LogtoID, system.SystemKey)
+	if err != nil {
+		logger.Error().Err(err).Str("system_id", systemID).Msg("list backups failed")
+		c.JSON(http.StatusBadGateway, response.Error(http.StatusBadGateway, "failed to list backups", nil))
+		return
+	}
+
+	c.JSON(http.StatusOK, response.OK("backups retrieved successfully", BackupListResponse{
+		Backups:        items,
+		QuotaUsedBytes: used,
+		SlotsUsed:      len(items),
+	}))
+}
+
+// DownloadSystemBackup handles GET /api/systems/:id/backups/:backup_id/download.
+// It responds with a 302 redirect to a short-lived presigned URL so the user's
+// browser can stream the object directly from Spaces.
+func DownloadSystemBackup(c *gin.Context) {
+	systemID := c.Param("id")
+	backupID := c.Param("backup_id")
+	if systemID == "" || backupID == "" {
+		c.JSON(http.StatusBadRequest, response.BadRequest("system ID and backup ID required", nil))
+		return
+	}
+	if !isValidBackupID(backupID) {
+		c.JSON(http.StatusBadRequest, response.BadRequest("invalid backup id", nil))
+		return
+	}
+
+	user, ok := helpers.GetUserFromContext(c)
+	if !ok {
+		return
+	}
+
+	systemsService := local.NewSystemsService()
+	system, err := systemsService.GetSystem(systemID, user.OrgRole, user.OrganizationID)
+	if helpers.HandleAccessError(c, err, "system", systemID) {
+		return
+	}
+
+	ctx := c.Request.Context()
+	client, presigner, err := storage.BackupClient(ctx)
+	if err != nil {
+		c.JSON(http.StatusServiceUnavailable, response.Error(http.StatusServiceUnavailable, "backup storage unavailable", nil))
+		return
+	}
+
+	key := fmt.Sprintf("%s/%s/%s", system.Organization.LogtoID, system.SystemKey, backupID)
+
+	// Verify the object exists before issuing a presigned URL. Otherwise
+	// the client gets a 200 with a URL that 404s on GET, which is
+	// confusing UX and leaks that the ID didn't exist only after an
+	// extra round-trip.
+	_, err = client.HeadObject(ctx, &s3.HeadObjectInput{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Key:    aws.String(key),
+	})
+	if err != nil {
+		var nf *s3types.NotFound
+		if errors.As(err, &nf) {
+			c.JSON(http.StatusNotFound, response.NotFound("backup not found", nil))
+			return
+		}
+		logger.Error().Err(err).Str("key", key).Msg("head backup failed")
+		c.JSON(http.StatusBadGateway, response.Error(http.StatusBadGateway, "failed to generate download URL", nil))
+		return
+	}
+
+	presigned, err := presigner.PresignGetObject(ctx, &s3.GetObjectInput{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Key:    aws.String(key),
+	}, s3.WithPresignExpires(configuration.Config.BackupPresignTTL))
+	if err != nil {
+		logger.Error().Err(err).Str("key", key).Msg("presign backup URL failed")
+		c.JSON(http.StatusBadGateway, response.Error(http.StatusBadGateway, "failed to generate download URL", nil))
+		return
+	}
+
+	logger.LogBusinessOperation(c, "systems", "download_backup", "backup", backupID, true, nil)
+	logger.RequestLogger(c, "systems").Info().
+		Str("operation", "download_backup").
+		Str("system_id", systemID).
+		Str("backup_id", backupID).
+		Int("expires_in_seconds", int(configuration.Config.BackupPresignTTL.Seconds())).
+		Msg("backup download URL issued")
+
+	c.JSON(http.StatusOK, response.OK("download URL issued", gin.H{
+		"download_url":       presigned.URL,
+		"expires_in_seconds": int(configuration.Config.BackupPresignTTL.Seconds()),
+	}))
+}
+
+// DeleteSystemBackup handles DELETE /api/systems/:id/backups/:backup_id —
+// removes a backup object from storage. RBAC-gated like the read endpoints.
+func DeleteSystemBackup(c *gin.Context) {
+	systemID := c.Param("id")
+	backupID := c.Param("backup_id")
+	if systemID == "" || backupID == "" {
+		c.JSON(http.StatusBadRequest, response.BadRequest("system ID and backup ID required", nil))
+		return
+	}
+	if !isValidBackupID(backupID) {
+		c.JSON(http.StatusBadRequest, response.BadRequest("invalid backup id", nil))
+		return
+	}
+
+	user, ok := helpers.GetUserFromContext(c)
+	if !ok {
+		return
+	}
+
+	systemsService := local.NewSystemsService()
+	system, err := systemsService.GetSystem(systemID, user.OrgRole, user.OrganizationID)
+	if helpers.HandleAccessError(c, err, "system", systemID) {
+		return
+	}
+
+	ctx := c.Request.Context()
+	client, _, err := storage.BackupClient(ctx)
+	if err != nil {
+		c.JSON(http.StatusServiceUnavailable, response.Error(http.StatusServiceUnavailable, "backup storage unavailable", nil))
+		return
+	}
+
+	key := fmt.Sprintf("%s/%s/%s", system.Organization.LogtoID, system.SystemKey, backupID)
+
+	// S3's DeleteObject is idempotent — it returns success whether the
+	// key existed or not — so a NoSuchKey error is never surfaced. Probe
+	// with HeadObject first so the caller sees a 404 for phantom IDs
+	// instead of a misleading 200.
+	_, err = client.HeadObject(ctx, &s3.HeadObjectInput{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Key:    aws.String(key),
+	})
+	if err != nil {
+		var nf *s3types.NotFound
+		if errors.As(err, &nf) {
+			c.JSON(http.StatusNotFound, response.NotFound("backup not found", nil))
+			return
+		}
+		logger.Error().Err(err).Str("key", key).Msg("head backup failed")
+		c.JSON(http.StatusBadGateway, response.Error(http.StatusBadGateway, "failed to delete backup", nil))
+		return
+	}
+
+	_, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Key:    aws.String(key),
+	})
+	if err != nil {
+		logger.Error().Err(err).Str("key", key).Msg("delete backup failed")
+		c.JSON(http.StatusBadGateway, response.Error(http.StatusBadGateway, "failed to delete backup", nil))
+		return
+	}
+
+	// Invalidate the per-org usage counter on the shared Redis so collect's
+	// next quota check recomputes from S3 instead of trusting a value that
+	// no longer reflects what's in the bucket.
+	cache.InvalidateOrgBackupUsage(ctx, system.Organization.LogtoID)
+
+	logger.LogBusinessOperation(c, "systems", "delete_backup", "backup", backupID, true, nil)
+
+	c.JSON(http.StatusOK, response.OK("backup deleted", gin.H{
+		"system_id": systemID,
+		"backup_id": backupID,
+	}))
+}
+
+// purgeSystemBackups removes every object stored under a system's S3
+// prefix. It is invoked by DestroySystem before the DB row goes away
+// to honour the GDPR Article 17 "right to erasure" — backups that
+// belong to a deleted system must not survive in storage. A non-nil
+// error means the destroy must be refused so the operator can retry.
+//
+// The function is a no-op when the backup storage client is not
+// configured (e.g. dev environments without BACKUP_S3_ENDPOINT set);
+// in that case there is nothing to purge.
+func purgeSystemBackups(ctx context.Context, orgID, systemKey string) error {
+	client, _, err := storage.BackupClient(ctx)
+	if err != nil {
+		// No backup storage configured for this environment.
+		logger.Warn().Err(err).Str("system_key", systemKey).Msg("backup storage unavailable; skipping purge")
+		return nil
+	}
+
+	prefix := fmt.Sprintf("%s/%s/", orgID, systemKey)
+	purged := 0
+	paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Prefix: aws.String(prefix),
+	})
+	for paginator.HasMorePages() {
+		page, err := paginator.NextPage(ctx)
+		if err != nil {
+			return fmt.Errorf("list backups for purge: %w", err)
+		}
+		for _, o := range page.Contents {
+			_, delErr := client.DeleteObject(ctx, &s3.DeleteObjectInput{
+				Bucket: aws.String(configuration.Config.BackupS3Bucket),
+				Key:    o.Key,
+			})
+			if delErr != nil {
+				return fmt.Errorf("delete %s: %w", aws.ToString(o.Key), delErr)
+			}
+			purged++
+		}
+	}
+
+	// Invalidate the per-org usage counter so collect's next upload's quota
+	// check sees the freed space.
+	if purged > 0 {
+		cache.InvalidateOrgBackupUsage(ctx, orgID)
+	}
+
+	logger.Info().
+		Str("system_key", systemKey).
+		Str("org_id", orgID).
+		Int("objects_purged", purged).
+		Msg("system backups purged")
+	return nil
+}
+
+// listSystemBackups returns the backups for a system along with the
+// total bytes stored under the prefix. Paginates explicitly so the
+// per-system list is never silently truncated at the S3 1000-item
+// response cap.
+func listSystemBackups(ctx context.Context, client *s3.Client, orgID, systemKey string) ([]BackupMetadata, int64, error) {
+	prefix := fmt.Sprintf("%s/%s/", orgID, systemKey)
+
+	items := make([]BackupMetadata, 0)
+	var total int64
+
+	paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Prefix: aws.String(prefix),
+	})
+	for paginator.HasMorePages() {
+		page, err := paginator.NextPage(ctx)
+		if err != nil {
+			return nil, 0, err
+		}
+		for _, o := range page.Contents {
+			key := aws.ToString(o.Key)
+			id := strings.TrimPrefix(key, prefix)
+
+			head, err := client.HeadObject(ctx, &s3.HeadObjectInput{
+				Bucket: aws.String(configuration.Config.BackupS3Bucket),
+				Key:    aws.String(key),
+			})
+			if err != nil {
+				logger.Warn().Err(err).Str("key", key).Msg("head failed during backup list")
+				continue
+			}
+
+			md := head.Metadata
+			size := aws.ToInt64(head.ContentLength)
+			total += size
+			items = append(items, BackupMetadata{
+				ID:         id,
+				Filename:   md["filename"],
+				Size:       size,
+				SHA256:     md["sha256"],
+				MimeType:   aws.ToString(head.ContentType),
+				UploadedAt: aws.ToTime(o.LastModified),
+			})
+		}
+	}
+
+	sort.Slice(items, func(i, j int) bool {
+		return items[i].UploadedAt.After(items[j].UploadedAt)
+	})
+
+	return items, total, nil
+}
diff --git a/backend/methods/backups_unit_test.go b/backend/methods/backups_unit_test.go
new file mode 100644
index 000000000..6a5291564
--- /dev/null
+++ b/backend/methods/backups_unit_test.go
@@ -0,0 +1,141 @@
+/*
+Copyright (C) 2026 Nethesis S.r.l.
+SPDX-License-Identifier: AGPL-3.0-or-later
+*/
+
+package methods
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// handlerPanicRecovery wraps a handler so panics surface as 500s during
+// tests instead of aborting the runner. The backup handlers reach for
+// database/S3 clients once they pass the early parameter/auth gates; in
+// the tests below we only exercise the short-circuit paths that return
+// before any of those side effects.
+func handlerPanicRecovery(h gin.HandlerFunc) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		defer func() {
+			if r := recover(); r != nil {
+				c.AbortWithStatus(http.StatusInternalServerError)
+			}
+		}()
+		h(c)
+	}
+}
+
+func TestIsValidBackupID(t *testing.T) {
+	tests := []struct {
+		name string
+		in   string
+		ok   bool
+	}{
+		{"uuidv7 bare", "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8", true},
+		{"uuidv7 with tar.gz", "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.tar.gz", true},
+		{"uuidv7 uppercase normalised", "01934FAB-BC33-7890-A1B2-C3D4E5F6A7B8.GPG", true},
+		{"path traversal", "../../etc/passwd", false},
+		{"slash injection", "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8/extra", false},
+		{"unknown extension", "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.sh", false},
+		{"empty", "", false},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.Equal(t, tc.ok, isValidBackupID(tc.in))
+		})
+	}
+}
+
+func TestGetSystemBackupsRequiresUserContext(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	router.GET("/systems/:id/backups", handlerPanicRecovery(GetSystemBackups))
+
+	req := httptest.NewRequest("GET", "/systems/sys_123/backups", nil)
+	w := httptest.NewRecorder()
+	router.ServeHTTP(w, req)
+
+	// helpers.GetUserFromContext writes its own 401 response and returns
+	// false when the auth middleware has not run — the handler must stop
+	// short of touching the DB.
+	assert.NotEqual(t, http.StatusOK, w.Code)
+}
+
+func TestDownloadSystemBackupRequiresUserContext(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	router.GET("/systems/:id/backups/:backup_id/download",
+		handlerPanicRecovery(DownloadSystemBackup))
+
+	req := httptest.NewRequest("GET",
+		"/systems/sys_123/backups/01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.tar.gz/download", nil)
+	w := httptest.NewRecorder()
+	router.ServeHTTP(w, req)
+
+	assert.NotEqual(t, http.StatusOK, w.Code)
+}
+
+func TestDeleteSystemBackupRequiresUserContext(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	router.DELETE("/systems/:id/backups/:backup_id",
+		handlerPanicRecovery(DeleteSystemBackup))
+
+	req := httptest.NewRequest("DELETE",
+		"/systems/sys_123/backups/01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.tar.gz", nil)
+	w := httptest.NewRecorder()
+	router.ServeHTTP(w, req)
+
+	assert.NotEqual(t, http.StatusOK, w.Code)
+}
+
+func TestBackupMetadataSerialization(t *testing.T) {
+	// Document the JSON shape the UI consumes. If any field name or
+	// casing shifts, this test highlights it before the frontend breaks.
+	m := BackupMetadata{
+		ID:       "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.tar.gz",
+		Filename: "daily-backup.tar.gz",
+		Size:     42 * 1024 * 1024,
+		SHA256:   "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b",
+		MimeType: "application/gzip",
+	}
+
+	payload, err := json.Marshal(m)
+	require.NoError(t, err)
+
+	var decoded map[string]interface{}
+	require.NoError(t, json.Unmarshal(payload, &decoded))
+
+	assert.Equal(t, m.ID, decoded["id"])
+	assert.Equal(t, m.Filename, decoded["filename"])
+	assert.Equal(t, float64(m.Size), decoded["size"])
+	assert.Equal(t, m.SHA256, decoded["sha256"])
+	assert.Equal(t, m.MimeType, decoded["mimetype"])
+	_, hasUploader := decoded["uploader_ip"]
+	assert.False(t, hasUploader, "uploader_ip must not be exposed in BackupMetadata")
+}
+
+func TestBackupListResponseSerialization(t *testing.T) {
+	r := BackupListResponse{
+		Backups:        []BackupMetadata{},
+		QuotaUsedBytes: 1024 * 1024,
+		SlotsUsed:      3,
+	}
+
+	payload, err := json.Marshal(r)
+	require.NoError(t, err)
+
+	var decoded map[string]interface{}
+	require.NoError(t, json.Unmarshal(payload, &decoded))
+
+	assert.Equal(t, float64(r.QuotaUsedBytes), decoded["quota_used_bytes"])
+	assert.Equal(t, float64(r.SlotsUsed), decoded["slots_used"])
+	assert.NotNil(t, decoded["backups"])
+}
diff --git a/backend/methods/systems.go b/backend/methods/systems.go
index 7b20caa11..f113e1a29 100644
--- a/backend/methods/systems.go
+++ b/backend/methods/systems.go
@@ -7,6 +7,7 @@ package methods
 
 import (
 	"crypto/tls"
+	"errors"
 	"fmt"
 	"net/http"
 	"strings"
@@ -15,6 +16,8 @@ import (
 	"github.com/gin-gonic/gin"
 	"github.com/gin-gonic/gin/binding"
 
+	"github.com/nethesis/my/backend/cache"
+	"github.com/nethesis/my/backend/entities"
 	"github.com/nethesis/my/backend/helpers"
 	"github.com/nethesis/my/backend/logger"
 	"github.com/nethesis/my/backend/models"
@@ -250,8 +253,18 @@ func UpdateSystem(c *gin.Context) {
 	// Create systems service
 	systemsService := local.NewSystemsService()
 
-	// Update system with access validation
-	system, err := systemsService.UpdateSystem(systemID, &request, user.ID, user.OrganizationID, user.OrgRole)
+	// Update system with access validation. Pass actor metadata so the
+	// cross-org transfer audit row (if any) records who made the change
+	// and from where.
+	reqCtx := local.UpdateSystemRequestContext{
+		ActorIP:   c.ClientIP(),
+		UserAgent: c.Request.UserAgent(),
+	}
+	system, err := systemsService.UpdateSystem(systemID, &request, user.ID, user.OrganizationID, user.OrgRole, reqCtx)
+	if errors.Is(err, cache.ErrSystemReassignLocked) {
+		c.JSON(http.StatusConflict, response.Error(http.StatusConflict, err.Error(), nil))
+		return
+	}
 	if helpers.HandleAccessError(c, err, "system", systemID) {
 		return
 	}
@@ -370,6 +383,41 @@ func DestroySystem(c *gin.Context) {
 
 	systemsService := local.NewSystemsService()
 
+	// Resolve the system before destroy so we know which S3 prefix to
+	// purge (the row will be gone after DestroySystem). The lookup
+	// bypasses the deleted_at filter so the two-step "soft delete, then
+	// destroy" flow still runs the purge — GetSystem would otherwise
+	// return "not found" on an already soft-deleted row and the GDPR
+	// erasure would be silently skipped. Skip the purge only when the
+	// row is genuinely missing; DestroySystem will surface the 404 below.
+	if system, lookupErr := systemsService.GetSystemIncludingDeleted(systemID, user.OrgRole, user.OrganizationID); lookupErr == nil {
+		// Build the set of orgs whose prefix may hold backups for this
+		// system: the current owner plus every donor recorded in the audit
+		// log. Without this, a partial cleanup failure during a previous
+		// reassignment can leave ciphertext under a donor prefix that the
+		// destroy never visits — a silent GDPR Article 17 gap.
+		orgsToPurge := []string{system.Organization.LogtoID}
+		seen := map[string]bool{system.Organization.LogtoID: true}
+		auditRepo := entities.NewLocalSystemOrgTransfersRepository()
+		if priorOrgs, err := auditRepo.PriorOrgIDsForSystem(systemID); err == nil {
+			for _, orgID := range priorOrgs {
+				if !seen[orgID] {
+					seen[orgID] = true
+					orgsToPurge = append(orgsToPurge, orgID)
+				}
+			}
+		} else {
+			logger.Warn().Err(err).Str("system_id", systemID).Msg("could not load prior orgs for purge; falling back to current org only")
+		}
+		for _, orgID := range orgsToPurge {
+			if purgeErr := purgeSystemBackups(c.Request.Context(), orgID, system.SystemKey); purgeErr != nil {
+				logger.Error().Err(purgeErr).Str("system_id", systemID).Str("org_id", orgID).Msg("backup purge failed; refusing to destroy system")
+				c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to purge system backups", nil))
+				return
+			}
+		}
+	}
+
 	err := systemsService.DestroySystem(systemID, user.ID, user.OrganizationID, user.OrgRole)
 	if err != nil {
 		errMsg := err.Error()
diff --git a/backend/openapi.yaml b/backend/openapi.yaml
index b91e2f573..869b744b3 100644
--- a/backend/openapi.yaml
+++ b/backend/openapi.yaml
@@ -735,6 +735,36 @@ components:
             format: int64
           example: [42, 43]
 
+    BackupMetadata:
+      type: object
+      description: Metadata describing a single configuration backup stored for a system.
+      properties:
+        id:
+          type: string
+          description: Backup object ID (UUIDv7 plus original extension)
+          example: "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.tar.gz"
+        filename:
+          type: string
+          description: User-facing filename as supplied by the appliance via X-Filename
+          example: "daily-backup-2026-04-12.tar.gz"
+        size:
+          type: integer
+          format: int64
+          description: Object size in bytes
+          example: 82944000
+        sha256:
+          type: string
+          description: Hex SHA-256 checksum computed at ingest; always present once the upload returns 201
+          example: "3a7bd3e2360a3d29eea436fcfb7e44c735d117c42d1c1835420b6b9942dd4f1b"
+        mimetype:
+          type: string
+          example: "application/gzip"
+        uploaded_at:
+          type: string
+          format: date-time
+          description: Server-observed timestamp of the upload
+          example: "2026-04-12T02:15:00Z"
+
     Pagination:
       type: object
       properties:
@@ -9221,6 +9251,211 @@ paths:
         '404':
           $ref: '#/components/responses/NotFound'
 
+  # ===========================================================================
+  # SYSTEM BACKUPS
+  # ===========================================================================
+  /systems/{id}/backups:
+    get:
+      operationId: getSystemBackups
+      tags:
+        - Backend - Systems Backups
+      summary: /systems/{id}/backups - List configuration backups for a system
+      description: |
+        Returns the list of configuration backups stored for the system,
+        together with aggregate usage counters. Backups are produced by
+        the appliance itself (see collect ingest endpoint) and consumed
+        here read-only.
+
+        Each entry carries `size`, `sha256`, and `uploaded_at`. The
+        peer IP observed at ingest is intentionally not exposed: on
+        traffic that transits the translation proxy the recorded value
+        would be the proxy's IP, and even when it is accurate it is a
+        reconnaissance aid for higher-tier admins.
+
+        Access is gated by the same RBAC rules as `GET /systems/{id}`:
+        the caller must belong to the organization that currently owns
+        the system. After a cross-org reassignment, the new owner sees
+        the full backup list and the previous owner loses visibility.
+      parameters:
+        - name: id
+          in: path
+          required: true
+          description: System ID
+          schema:
+            type: string
+            example: "sys_123456789"
+      responses:
+        '200':
+          description: Backups retrieved successfully
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  code:
+                    type: integer
+                    example: 200
+                  message:
+                    type: string
+                    example: "backups retrieved successfully"
+                  data:
+                    type: object
+                    properties:
+                      backups:
+                        type: array
+                        items:
+                          $ref: '#/components/schemas/BackupMetadata'
+                      quota_used_bytes:
+                        type: integer
+                        format: int64
+                        description: Sum of backup sizes stored for this system
+                        example: 314572800
+                      slots_used:
+                        type: integer
+                        description: Number of backups currently stored
+                        example: 4
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '502':
+          description: Backup storage unreachable or returned an error
+        '503':
+          description: Backup storage is not configured
+
+  /systems/{id}/backups/{backup_id}/download:
+    get:
+      operationId: downloadSystemBackup
+      tags:
+        - Backend - Systems Backups
+      summary: /systems/{id}/backups/{backup_id}/download - Issue a short-lived download URL
+      description: |
+        Returns a short-lived presigned S3 URL that the user's browser
+        uses to stream the backup object directly from storage. The API
+        never proxies the object body itself.
+
+        The backend does not perform a redirect because the frontend
+        sends its JWT on the initial request — browsers would drop the
+        `Authorization` header when following a 3xx redirect, so the
+        frontend receives the URL in the JSON response and navigates to
+        it explicitly.
+
+        The presigned URL's lifetime is controlled by the
+        `BACKUP_PRESIGN_TTL` environment variable (default 5 minutes).
+
+        Access is gated by the same RBAC rules as `GET /systems/{id}`.
+      parameters:
+        - name: id
+          in: path
+          required: true
+          description: System ID
+          schema:
+            type: string
+            example: "sys_123456789"
+        - name: backup_id
+          in: path
+          required: true
+          description: Backup object ID (UUIDv7 + extension, e.g. "01934f...tar.gz")
+          schema:
+            type: string
+            example: "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.tar.gz"
+      responses:
+        '200':
+          description: Download URL issued
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  code:
+                    type: integer
+                    example: 200
+                  message:
+                    type: string
+                    example: "download URL issued"
+                  data:
+                    type: object
+                    properties:
+                      download_url:
+                        type: string
+                        format: uri
+                        description: Short-lived presigned S3 URL
+                      expires_in_seconds:
+                        type: integer
+                        description: How long the URL remains valid
+                        example: 300
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '502':
+          description: Backup storage unreachable or returned an error
+        '503':
+          description: Backup storage is not configured
+
+    delete:
+      operationId: deleteSystemBackup
+      tags:
+        - Backend - Systems Backups
+      summary: /systems/{id}/backups/{backup_id} - Delete a stored backup
+      description: |
+        Deletes a backup object from storage. The operation is final —
+        storage uses object-level deletion, not a soft-delete table.
+
+        Access is gated by the same RBAC rules as `GET /systems/{id}`.
+      parameters:
+        - name: id
+          in: path
+          required: true
+          description: System ID
+          schema:
+            type: string
+            example: "sys_123456789"
+        - name: backup_id
+          in: path
+          required: true
+          description: Backup object ID
+          schema:
+            type: string
+            example: "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.tar.gz"
+      responses:
+        '200':
+          description: Backup deleted
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  code:
+                    type: integer
+                    example: 200
+                  message:
+                    type: string
+                    example: "backup deleted"
+                  data:
+                    type: object
+                    properties:
+                      system_id:
+                        type: string
+                        example: "sys_123456789"
+                      backup_id:
+                        type: string
+                        example: "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.tar.gz"
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '403':
+          $ref: '#/components/responses/Forbidden'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '502':
+          description: Backup storage unreachable or returned an error
+        '503':
+          description: Backup storage is not configured
+
   # ===========================================================================
   # APPLICATIONS MANAGEMENT
   # ===========================================================================
@@ -10211,6 +10446,179 @@ paths:
         '500':
           $ref: '#/components/responses/InternalServerError'
 
+  /systems/backups:
+    post:
+      operationId: uploadSystemBackup
+      servers:
+        - url: https://collect.your-domain.com/api
+          description: Collect API server (port 8081)
+      tags:
+        - Collect - Systems
+      summary: /systems/backups - Upload a configuration backup
+      description: |
+        Stream a GPG-encrypted configuration snapshot to the backup store.
+        The body is the ciphertext; SHA-256 is computed at ingest. The
+        appliance authenticates with HTTP Basic auth using its
+        `system_key:system_secret`.
+
+        Server-side limits enforced before/during ingest:
+        - Per-system rate limit (default 6/min, 60/hour) — `429 Retry-After` on hit
+        - Per-system slot cap (default 10) — oldest pruned at retention
+        - Per-system size cap (default 500 MiB total) — oldest pruned
+        - Per-org aggregate quota (default 100 GiB) — `413` on hit
+        - Per-upload size cap (default 2 GiB) — `413` on hit
+
+        Object key shape: `{org_id}/{system_key}/{uuidv7}.{ext}`. The
+        appliance never picks the destination prefix; it is server-derived
+        from the authenticated identity.
+      security:
+        - BasicAuth: [system_key:system_secret]
+      parameters:
+        - name: X-Filename
+          in: header
+          required: false
+          description: User-facing filename (sanitized server-side)
+          schema:
+            type: string
+            example: "daily-backup-2026-04-12.tar.gz.gpg"
+      requestBody:
+        required: true
+        content:
+          application/octet-stream:
+            schema:
+              type: string
+              format: binary
+      responses:
+        '201':
+          description: Backup stored
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  code:
+                    type: integer
+                    example: 201
+                  message:
+                    type: string
+                    example: "backup stored"
+                  data:
+                    $ref: '#/components/schemas/BackupMetadata'
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '413':
+          $ref: '#/components/responses/RequestEntityTooLarge'
+        '429':
+          description: Rate limit exceeded; retry after the time indicated by `Retry-After`.
+        '503':
+          description: Backup storage or org-quota service unavailable; retry later.
+    get:
+      operationId: listSystemBackupsAppliance
+      servers:
+        - url: https://collect.your-domain.com/api
+          description: Collect API server (port 8081)
+      tags:
+        - Collect - Systems
+      summary: /systems/backups - List backups for the authenticated system
+      description: |
+        Returns metadata for every backup currently stored under the
+        authenticated system's prefix. Cross-tenant access is impossible
+        — the listing scope is server-derived from the credentials.
+      security:
+        - BasicAuth: [system_key:system_secret]
+      responses:
+        '200':
+          description: Backups listed
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  code:
+                    type: integer
+                    example: 200
+                  data:
+                    type: object
+                    properties:
+                      backups:
+                        type: array
+                        items:
+                          $ref: '#/components/schemas/BackupMetadata'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '503':
+          description: Backup storage unavailable.
+
+  /systems/backups/{id}:
+    get:
+      operationId: downloadSystemBackupAppliance
+      servers:
+        - url: https://collect.your-domain.com/api
+          description: Collect API server (port 8081)
+      tags:
+        - Collect - Systems
+      summary: /systems/backups/{id} - Download a backup
+      description: |
+        Stream the ciphertext body for a specific backup belonging to the
+        authenticated system. Foreign IDs return `404`; the path can never
+        escape the authenticated system's prefix.
+      security:
+        - BasicAuth: [system_key:system_secret]
+      parameters:
+        - name: id
+          in: path
+          required: true
+          description: Backup ID (UUIDv7 plus extension)
+          schema:
+            type: string
+            example: "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.tar.gz"
+      responses:
+        '200':
+          description: Backup body streamed as `application/octet-stream`
+          content:
+            application/octet-stream:
+              schema:
+                type: string
+                format: binary
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '404':
+          $ref: '#/components/responses/NotFound'
+        '503':
+          description: Backup storage unavailable.
+    delete:
+      operationId: deleteSystemBackupAppliance
+      servers:
+        - url: https://collect.your-domain.com/api
+          description: Collect API server (port 8081)
+      tags:
+        - Collect - Systems
+      summary: /systems/backups/{id} - Delete a backup
+      description: |
+        Remove a specific backup belonging to the authenticated system.
+        Foreign IDs return `404`.
+      security:
+        - BasicAuth: [system_key:system_secret]
+      parameters:
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        '200':
+          description: Backup deleted
+        '400':
+          $ref: '#/components/responses/BadRequest'
+        '401':
+          $ref: '#/components/responses/Unauthorized'
+        '404':
+          $ref: '#/components/responses/NotFound'
+
   # ===========================================================================
   # VALIDATORS
   # ===========================================================================
diff --git a/backend/services/alerting/client.go b/backend/services/alerting/client.go
index f48af9806..4afbdc473 100644
--- a/backend/services/alerting/client.go
+++ b/backend/services/alerting/client.go
@@ -284,6 +284,65 @@ func GetSilence(orgID, silenceID string) (*models.AlertmanagerSilence, error) {
 	return &silence, nil
 }
 
+// CleanupSystemSilences removes every silence in the given Mimir tenant that
+// targets the supplied system_key — typically the donor organization right
+// after a system has been reassigned away from it. The match must be exact
+// and non-regex, so silences that were broadened to cover a wider set of
+// systems are left in place. Already-expired silences are skipped because
+// Mimir garbage-collects them on its own.
+//
+// The function is best-effort: a partial failure (one silence DELETE returns
+// an error) is logged and the loop continues so as many orphans as possible
+// are cleared. Returns the count of silences deleted along with the first
+// error encountered, if any.
+func CleanupSystemSilences(orgID, systemKey string) (int, error) {
+	silences, err := GetSilences(orgID)
+	if err != nil {
+		return 0, fmt.Errorf("list silences in org %s: %w", orgID, err)
+	}
+
+	deleted := 0
+	var firstErr error
+	for _, s := range silences {
+		if s.Status != nil && s.Status.State == "expired" {
+			continue
+		}
+		if !silenceTargetsSystem(s.Matchers, systemKey) {
+			continue
+		}
+		if err := DeleteSilence(orgID, s.ID); err != nil {
+			if errors.Is(err, ErrSilenceNotFound) {
+				continue
+			}
+			if firstErr == nil {
+				firstErr = err
+			}
+			continue
+		}
+		deleted++
+	}
+
+	return deleted, firstErr
+}
+
+// silenceTargetsSystem reports whether the silence is *exclusively* scoped
+// to the given system. We require exactly one matcher equal to
+// `system_key=<key>`, non-regex, non-negated. A silence with extra matchers
+// (e.g. `system_key=X AND alertname=DiskFull`) carries a narrower intent
+// the donor admin set up — we leave it alone rather than risk wiping a
+// maintenance window. Such silences expire naturally at endsAt.
+//
+// Regex matchers and `isEqual=false` (negation) matchers are likewise
+// rejected: both alias to "wider scope than just this system" and must
+// never be silently deleted by an org reassignment.
+func silenceTargetsSystem(matchers []models.AlertmanagerMatcher, systemKey string) bool {
+	if len(matchers) != 1 {
+		return false
+	}
+	m := matchers[0]
+	return m.Name == "system_key" && m.Value == systemKey && !m.IsRegex
+}
+
 // DeleteSilence deletes a specific Alertmanager silence for the given tenant.
 func DeleteSilence(orgID, silenceID string) error {
 	url := configuration.Config.MimirURL + "/alertmanager/api/v2/silence/" + url.PathEscape(silenceID)
diff --git a/backend/services/alerting/client_test.go b/backend/services/alerting/client_test.go
index a9013cfb2..fc4b4a0a4 100644
--- a/backend/services/alerting/client_test.go
+++ b/backend/services/alerting/client_test.go
@@ -181,3 +181,134 @@ func TestDeleteSilenceReturnsNotFound(t *testing.T) {
 	require.Error(t, err)
 	assert.True(t, errors.Is(err, ErrSilenceNotFound))
 }
+
+func TestCleanupSystemSilencesDeletesOnlyExactMatches(t *testing.T) {
+	oldClient := httpClient
+	oldMimirURL := configuration.Config.MimirURL
+	defer func() {
+		httpClient = oldClient
+		configuration.Config.MimirURL = oldMimirURL
+	}()
+
+	silences := []models.AlertmanagerSilence{
+		{
+			ID: "match-active",
+			Matchers: []models.AlertmanagerMatcher{
+				{Name: "system_key", Value: "system-1", IsRegex: false},
+			},
+			Status: &models.AlertmanagerSilenceStatus{State: "active"},
+		},
+		{
+			ID: "match-expired",
+			Matchers: []models.AlertmanagerMatcher{
+				{Name: "system_key", Value: "system-1", IsRegex: false},
+			},
+			Status: &models.AlertmanagerSilenceStatus{State: "expired"},
+		},
+		{
+			ID: "different-system",
+			Matchers: []models.AlertmanagerMatcher{
+				{Name: "system_key", Value: "system-2", IsRegex: false},
+			},
+			Status: &models.AlertmanagerSilenceStatus{State: "active"},
+		},
+		{
+			ID: "regex-broader",
+			Matchers: []models.AlertmanagerMatcher{
+				{Name: "system_key", Value: "system-.*", IsRegex: true},
+			},
+			Status: &models.AlertmanagerSilenceStatus{State: "active"},
+		},
+		{
+			// Narrow-scope silence (system_key + alertname). The donor admin
+			// set this up intentionally; reassignment must NOT wipe it.
+			ID: "multi-matcher-narrow-scope",
+			Matchers: []models.AlertmanagerMatcher{
+				{Name: "system_key", Value: "system-1", IsRegex: false},
+				{Name: "alertname", Value: "DiskFull", IsRegex: false},
+			},
+			Status: &models.AlertmanagerSilenceStatus{State: "active"},
+		},
+	}
+
+	deleted := map[string]bool{}
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, "org-1", r.Header.Get("X-Scope-OrgID"))
+		switch {
+		case r.Method == http.MethodGet && r.URL.Path == "/alertmanager/api/v2/silences":
+			w.Header().Set("Content-Type", "application/json")
+			_ = json.NewEncoder(w).Encode(silences)
+		case r.Method == http.MethodDelete:
+			id := r.URL.Path[len("/alertmanager/api/v2/silence/"):]
+			deleted[id] = true
+			w.WriteHeader(http.StatusOK)
+		default:
+			http.NotFound(w, r)
+		}
+	}))
+	defer server.Close()
+
+	httpClient = server.Client()
+	configuration.Config.MimirURL = server.URL
+
+	count, err := CleanupSystemSilences("org-1", "system-1")
+	require.NoError(t, err)
+	assert.Equal(t, 1, count)
+	assert.Equal(t, map[string]bool{"match-active": true}, deleted)
+}
+
+func TestCleanupSystemSilencesContinuesOnPartialFailure(t *testing.T) {
+	oldClient := httpClient
+	oldMimirURL := configuration.Config.MimirURL
+	defer func() {
+		httpClient = oldClient
+		configuration.Config.MimirURL = oldMimirURL
+	}()
+
+	silences := []models.AlertmanagerSilence{
+		{
+			ID: "ok-1",
+			Matchers: []models.AlertmanagerMatcher{
+				{Name: "system_key", Value: "system-1", IsRegex: false},
+			},
+		},
+		{
+			ID: "fail",
+			Matchers: []models.AlertmanagerMatcher{
+				{Name: "system_key", Value: "system-1", IsRegex: false},
+			},
+		},
+		{
+			ID: "ok-2",
+			Matchers: []models.AlertmanagerMatcher{
+				{Name: "system_key", Value: "system-1", IsRegex: false},
+			},
+		},
+	}
+
+	deleted := map[string]bool{}
+	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.Method {
+		case http.MethodGet:
+			_ = json.NewEncoder(w).Encode(silences)
+		case http.MethodDelete:
+			id := r.URL.Path[len("/alertmanager/api/v2/silence/"):]
+			if id == "fail" {
+				http.Error(w, "boom", http.StatusInternalServerError)
+				return
+			}
+			deleted[id] = true
+			w.WriteHeader(http.StatusOK)
+		}
+	}))
+	defer server.Close()
+
+	httpClient = server.Client()
+	configuration.Config.MimirURL = server.URL
+
+	count, err := CleanupSystemSilences("org-1", "system-1")
+	require.Error(t, err)
+	assert.Equal(t, 2, count)
+	assert.True(t, deleted["ok-1"])
+	assert.True(t, deleted["ok-2"])
+}
diff --git a/backend/services/local/systems.go b/backend/services/local/systems.go
index 420f08061..5ca043d75 100644
--- a/backend/services/local/systems.go
+++ b/backend/services/local/systems.go
@@ -10,6 +10,7 @@
 package local
 
 import (
+	"context"
 	"crypto/rand"
 	"database/sql"
 	"encoding/hex"
@@ -21,12 +22,15 @@ import (
 	"github.com/google/uuid"
 	"github.com/lib/pq"
 
+	"github.com/nethesis/my/backend/cache"
 	"github.com/nethesis/my/backend/database"
 	"github.com/nethesis/my/backend/entities"
 	"github.com/nethesis/my/backend/helpers"
 	"github.com/nethesis/my/backend/logger"
 	"github.com/nethesis/my/backend/models"
+	"github.com/nethesis/my/backend/services/alerting"
 	"github.com/nethesis/my/backend/services/logto"
+	"github.com/nethesis/my/backend/storage"
 )
 
 // LocalSystemsService handles business logic for systems management
@@ -312,16 +316,47 @@ func (s *LocalSystemsService) GetSystem(systemID, userOrgRole, userOrgID string)
 	return system, nil
 }
 
-// UpdateSystem updates an existing system with access validation
-func (s *LocalSystemsService) UpdateSystem(systemID string, request *models.UpdateSystemRequest, userID, userOrgID, userOrgRole string) (*models.System, error) {
+// GetSystemIncludingDeleted is like GetSystem but also returns a system that
+// has already been soft-deleted. It is used by the destroy flow so a backup
+// purge can resolve the (org_id, system_key) tuple even for a row whose
+// deleted_at is set — GetSystem would otherwise return "system not found"
+// and the purge would be silently skipped, leaving ciphertext in the bucket.
+func (s *LocalSystemsService) GetSystemIncludingDeleted(systemID, userOrgRole, userOrgID string) (*models.System, error) {
+	systemRepo := entities.NewLocalSystemRepository()
+	system, err := systemRepo.GetByIDIncludingDeleted(systemID)
+	if err != nil {
+		return nil, err
+	}
+
+	if canAccess, reason := s.CanAccessSystem(system, userOrgRole, userOrgID); !canAccess {
+		return nil, fmt.Errorf("access denied: %s", reason)
+	}
+
+	return system, nil
+}
+
+// UpdateSystemRequestContext carries the actor metadata used by the audit log
+// (cross-org transfer events) and ties a reassignment back to the originating
+// HTTP request. Pass an empty struct from non-HTTP code paths.
+type UpdateSystemRequestContext struct {
+	ActorIP   string
+	UserAgent string
+}
+
+// UpdateSystem updates an existing system with access validation. When the
+// caller changes organization_id the function also runs the cross-org
+// migration of system-scoped resources (backups, silences, alert history,
+// app assignments) under a Redis advisory lock that serialises concurrent
+// PUTs against the same system, and writes an immutable audit row.
+func (s *LocalSystemsService) UpdateSystem(systemID string, request *models.UpdateSystemRequest, userID, userOrgID, userOrgRole string, reqCtx UpdateSystemRequestContext) (*models.System, error) {
 	// Get the system first to check permissions
 	system, err := s.GetSystem(systemID, userOrgRole, userOrgID)
 	if err != nil {
 		return nil, err
 	}
 
-	// Validate update permissions based on created_by
-	if canUpdate, reason := s.CanUpdateSystemByCreator(userOrgRole, userOrgID, &system.CreatedBy); !canUpdate {
+	// Validate update permissions on the current owning org
+	if canUpdate, reason := s.CanUpdateSystem(system, userOrgRole, userOrgID); !canUpdate {
 		return nil, fmt.Errorf("access denied: %s", reason)
 	}
 
@@ -333,7 +368,9 @@ func (s *LocalSystemsService) UpdateSystem(systemID string, request *models.Upda
 	}
 	// Note: Type and SystemKey are not modifiable via update API
 	// Validate organization_id change if provided
-	if request.OrganizationID != "" && request.OrganizationID != system.Organization.LogtoID {
+	previousOrgID := system.Organization.LogtoID
+	orgChanging := request.OrganizationID != "" && request.OrganizationID != previousOrgID
+	if orgChanging {
 		// Validate user can assign system to the new organization
 		if canCreate, reason := s.CanCreateSystemForOrganization(userOrgRole, userOrgID, request.OrganizationID); !canCreate {
 			return nil, fmt.Errorf("access denied for organization change: %s", reason)
@@ -358,6 +395,38 @@ func (s *LocalSystemsService) UpdateSystem(systemID string, request *models.Upda
 		return nil, fmt.Errorf("failed to marshal custom_data: %w", err)
 	}
 
+	// Take a Redis-backed advisory lock for the entire reassignment block.
+	// Without it, two concurrent PUTs with different target orgs would both
+	// pass validation, both copy the donor prefix to their respective
+	// destinations, and only one's UPDATE would commit — leaving live
+	// ciphertext under the losing destination's prefix where it is readable
+	// by that org's admin (a real cross-tenant data leak). Failure to
+	// acquire the lock returns 409.
+	if orgChanging {
+		release, err := cache.AcquireSystemReassignLock(context.Background(), systemID, 5*time.Minute)
+		if err != nil {
+			return nil, err
+		}
+		defer release()
+	}
+
+	// Copy backups onto the destination organization's S3 prefix BEFORE the
+	// authoritative organization_id flip. Backup reads on `my` resolve the
+	// prefix from the system's current organization, so the new owner must
+	// see its objects from the moment the row updates. CopyObject is
+	// idempotent: a partial run can be retried without producing duplicates.
+	// A failure here aborts the reassignment so the source prefix stays
+	// authoritative and the admin can retry safely.
+	var backupsCopied int
+	if orgChanging {
+		ctx := context.Background()
+		copied, err := storage.CopyBackupPrefix(ctx, previousOrgID, system.Organization.LogtoID, system.SystemKey)
+		if err != nil {
+			return nil, fmt.Errorf("failed to migrate system backups across organizations: %w", err)
+		}
+		backupsCopied = copied
+	}
+
 	// Update system in database (FQDN and IP addresses are managed by collect service)
 	// Note: type and system_key are not modifiable
 	query := `
@@ -371,9 +440,104 @@ func (s *LocalSystemsService) UpdateSystem(systemID string, request *models.Upda
 		return nil, fmt.Errorf("failed to update system: %w", err)
 	}
 
+	// After the row has flipped, drop the source-org backups, prune silences
+	// targeting this system in the donor tenant, follow alert history into
+	// the new tenant, and clear app-to-org assignments scoped to the donor's
+	// RBAC hierarchy. Each step is best-effort and tracked with a counter
+	// for the audit row.
+	var backupsDeleted, silencesCleared, historyRows, appsUnassigned int
+	if orgChanging {
+		ctx := context.Background()
+		if deleted, err := storage.DeleteBackupPrefix(ctx, previousOrgID, system.SystemKey); err != nil {
+			logger.Warn().Err(err).
+				Str("system_id", systemID).
+				Str("system_key", system.SystemKey).
+				Str("previous_org_id", previousOrgID).
+				Msg("source-org backup cleanup failed after reassignment; orphan objects left under donor prefix")
+		} else {
+			backupsDeleted = deleted
+		}
+		// Both org-usage counters in Redis are now stale: the donor lost
+		// `backupsDeleted` bytes and the recipient gained `backupsCopied`
+		// bytes, but neither was incrementally updated by collect (collect
+		// only sees the appliance's own writes). Drop both keys so the next
+		// quota check on either side recomputes from S3.
+		cache.InvalidateOrgBackupUsage(ctx, previousOrgID)
+		cache.InvalidateOrgBackupUsage(ctx, system.Organization.LogtoID)
+		if cleared, err := alerting.CleanupSystemSilences(previousOrgID, system.SystemKey); err != nil {
+			logger.Warn().Err(err).
+				Str("system_id", systemID).
+				Str("system_key", system.SystemKey).
+				Str("previous_org_id", previousOrgID).
+				Msg("source-org silence cleanup failed after reassignment; donor-tenant silences will expire naturally")
+			silencesCleared = cleared
+		} else {
+			silencesCleared = cleared
+		}
+		// Move alert_history rows to the new organization so the new owner
+		// sees the system's full history and the donor — which no longer
+		// owns the system — loses visibility along with the row.
+		alertHistoryRepo := entities.NewLocalAlertHistoryRepository()
+		if rows, err := alertHistoryRepo.ReassignSystemAlertHistory(system.SystemKey, previousOrgID, system.Organization.LogtoID); err != nil {
+			logger.Warn().Err(err).
+				Str("system_id", systemID).
+				Str("system_key", system.SystemKey).
+				Str("previous_org_id", previousOrgID).
+				Msg("alert history reassignment failed after org change; rows remain attributed to donor and will be invisible to the new owner")
+		} else {
+			historyRows = int(rows)
+		}
+		// Strip organization assignments from every application that lives on
+		// this system: the donor's RBAC scope is gone; the new owner may sit
+		// in a different hierarchy where the previously chosen org has no
+		// meaning. The apps themselves (system_id FK, inventory data) stay
+		// with the system.
+		appsRepo := entities.NewLocalApplicationRepository()
+		if rows, err := appsRepo.UnassignAllForSystem(systemID); err != nil {
+			logger.Warn().Err(err).
+				Str("system_id", systemID).
+				Str("previous_org_id", previousOrgID).
+				Msg("failed to clear app organization assignments after org change; apps remain assigned to the donor's hierarchy")
+		} else {
+			appsUnassigned = int(rows)
+			cache.GetAppsCache().InvalidateAll()
+		}
+
+		// Append the immutable audit row last, so a forensic query has both
+		// the actor identity and the outcome counters in a single record.
+		// Failure to write the audit row degrades to a warning: the system
+		// is already authoritatively owned by the new organization, refusing
+		// the request now would be misleading. The structured logger keeps
+		// a fallback trace.
+		auditRepo := entities.NewLocalSystemOrgTransfersRepository()
+		auditRow := &entities.SystemOrgTransfer{
+			SystemID:              systemID,
+			SystemKey:             system.SystemKey,
+			FromOrgID:             previousOrgID,
+			ToOrgID:               system.Organization.LogtoID,
+			ActorUserID:           sql.NullString{String: userID, Valid: userID != ""},
+			ActorOrganizationID:   sql.NullString{String: userOrgID, Valid: userOrgID != ""},
+			ActorIP:               sql.NullString{String: reqCtx.ActorIP, Valid: reqCtx.ActorIP != ""},
+			UserAgent:             sql.NullString{String: reqCtx.UserAgent, Valid: reqCtx.UserAgent != ""},
+			BackupsCopied:         backupsCopied,
+			BackupsDeleted:        backupsDeleted,
+			SilencesCleared:       silencesCleared,
+			HistoryRowsReassigned: historyRows,
+			AppsUnassigned:        appsUnassigned,
+		}
+		if _, err := auditRepo.Insert(auditRow); err != nil {
+			logger.Warn().Err(err).
+				Str("system_id", systemID).
+				Str("from_org_id", previousOrgID).
+				Str("to_org_id", system.Organization.LogtoID).
+				Msg("failed to record cross-org transfer audit row")
+		}
+	}
+
 	logger.Info().
 		Str("system_id", systemID).
 		Str("updated_by", userID).
+		Bool("org_changed", orgChanging).
 		Msg("System updated successfully")
 
 	return system, nil
@@ -388,7 +552,7 @@ func (s *LocalSystemsService) DeleteSystem(systemID, userID, userOrgID, userOrgR
 	}
 
 	// Validate delete permissions based on created_by
-	if canDelete, reason := s.CanDeleteSystemByCreator(userOrgRole, userOrgID, &system.CreatedBy); !canDelete {
+	if canDelete, reason := s.CanDeleteSystem(system, userOrgRole, userOrgID); !canDelete {
 		return fmt.Errorf("access denied: %s", reason)
 	}
 
@@ -409,6 +573,8 @@ func (s *LocalSystemsService) DeleteSystem(systemID, userID, userOrgID, userOrgR
 		return fmt.Errorf("system not found")
 	}
 
+	cache.InvalidateSystemAuth(context.Background(), system.SystemKey)
+
 	logger.Info().
 		Str("system_id", systemID).
 		Str("deleted_by", userID).
@@ -496,7 +662,7 @@ func (s *LocalSystemsService) RestoreSystem(systemID, userID, userOrgID, userOrg
 
 	// Validate delete permissions based on created_by
 	// Use same permission check as delete - if user could delete it, they can restore it
-	if canDelete, reason := s.CanDeleteSystemByCreator(userOrgRole, userOrgID, &system.CreatedBy); !canDelete {
+	if canDelete, reason := s.CanDeleteSystem(system, userOrgRole, userOrgID); !canDelete {
 		return fmt.Errorf("access denied: %s", reason)
 	}
 
@@ -574,7 +740,7 @@ func (s *LocalSystemsService) DestroySystem(systemID, userID, userOrgID, userOrg
 	}
 
 	// Validate permissions based on created_by
-	if canDelete, reason := s.CanDeleteSystemByCreator(userOrgRole, userOrgID, &system.CreatedBy); !canDelete {
+	if canDelete, reason := s.CanDeleteSystem(system, userOrgRole, userOrgID); !canDelete {
 		return fmt.Errorf("access denied: %s", reason)
 	}
 
@@ -585,6 +751,8 @@ func (s *LocalSystemsService) DestroySystem(systemID, userID, userOrgID, userOrg
 		return fmt.Errorf("failed to hard-delete system: %w", err)
 	}
 
+	cache.InvalidateSystemAuth(context.Background(), system.SystemKey)
+
 	logger.Info().
 		Str("system_id", systemID).
 		Str("destroyed_by", userID).
@@ -602,7 +770,7 @@ func (s *LocalSystemsService) RegenerateSystemSecret(systemID, userID, userOrgID
 	}
 
 	// Validate update permissions (regenerating secret is an update operation) based on created_by
-	if canUpdate, reason := s.CanUpdateSystemByCreator(userOrgRole, userOrgID, &system.CreatedBy); !canUpdate {
+	if canUpdate, reason := s.CanUpdateSystem(system, userOrgRole, userOrgID); !canUpdate {
 		return nil, fmt.Errorf("access denied: %s", reason)
 	}
 
@@ -636,6 +804,8 @@ func (s *LocalSystemsService) RegenerateSystemSecret(systemID, userID, userOrgID
 	system.UpdatedAt = now
 	system.SystemSecret = fullToken
 
+	cache.InvalidateSystemAuth(context.Background(), system.SystemKey)
+
 	logger.Info().
 		Str("system_id", systemID).
 		Str("regenerated_by", userID).
@@ -759,100 +929,63 @@ func (s *LocalSystemsService) GetTotals(userOrgRole, userOrgID string, timeoutMi
 // Systems can now be created by any authenticated user
 // Access control is based on the created_by field which is set to the creator's organization
 
-// CanUpdateSystemByCreator validates if a user can update a system based on created_by organization
-func (s *LocalSystemsService) CanUpdateSystemByCreator(userOrgRole, userOrgID string, creator *models.SystemCreator) (bool, string) {
-	// Normalize organization role to lowercase for case-insensitive comparison
-	normalizedOrgRole := strings.ToLower(userOrgRole)
+// canActOnSystem implements the read/update/delete RBAC gate. The decision
+// is taken on the system's CURRENT owning organization (system.Organization.LogtoID),
+// not on the creator org (system.CreatedBy.OrganizationID): a system that has
+// been reassigned to another customer must lose visibility from the donor and
+// gain visibility from the recipient. The creator field stays as audit metadata.
+//
+// `verb` is purely cosmetic — it shapes the denial message ("access" / "update"
+// / "delete") so audit logs and API errors stay readable.
+func (s *LocalSystemsService) canActOnSystem(system *models.System, userOrgRole, userOrgID, verb string) (bool, string) {
+	normalizedRole := strings.ToLower(userOrgRole)
+	ownerOrgID := system.Organization.LogtoID
 
-	switch normalizedOrgRole {
+	switch normalizedRole {
 	case "owner":
 		return true, ""
 	case "distributor":
-		// Distributor can update systems created by organizations they manage hierarchically
 		userService := NewUserService()
-		if userService.IsOrganizationInHierarchy(normalizedOrgRole, userOrgID, creator.OrganizationID) {
+		if userService.IsOrganizationInHierarchy(normalizedRole, userOrgID, ownerOrgID) {
 			return true, ""
 		}
-		return false, "distributors can only update systems created by organizations they manage"
+		return false, fmt.Sprintf("distributors can only %s systems owned by organizations they manage", verb)
 	case "reseller":
-		// Reseller can update systems created by their own organization
-		if creator.OrganizationID == userOrgID {
+		if ownerOrgID == userOrgID {
 			return true, ""
 		}
-		return false, "resellers can only update systems created by their own organization"
-	case "customer":
-		// Customers can only update systems they created themselves
-		if creator.OrganizationID == userOrgID {
-			return true, ""
-		}
-		return false, "customers can only update systems created by their own organization"
-	default:
-		return false, "insufficient permissions to update systems"
-	}
-}
-
-// CanDeleteSystemByCreator validates if a user can delete a system based on created_by organization
-func (s *LocalSystemsService) CanDeleteSystemByCreator(userOrgRole, userOrgID string, creator *models.SystemCreator) (bool, string) {
-	// Normalize organization role to lowercase for case-insensitive comparison
-	normalizedOrgRole := strings.ToLower(userOrgRole)
-
-	switch normalizedOrgRole {
-	case "owner":
-		return true, ""
-	case "distributor":
-		// Distributor can delete systems created by organizations they manage hierarchically
 		userService := NewUserService()
-		if userService.IsOrganizationInHierarchy(normalizedOrgRole, userOrgID, creator.OrganizationID) {
-			return true, ""
-		}
-		return false, "distributors can only delete systems created by organizations they manage"
-	case "reseller":
-		// Reseller can delete systems created by their own organization
-		if creator.OrganizationID == userOrgID {
+		if userService.IsOrganizationInHierarchy(normalizedRole, userOrgID, ownerOrgID) {
 			return true, ""
 		}
-		return false, "resellers can only delete systems created by their own organization"
+		return false, fmt.Sprintf("resellers can only %s systems owned by their own organization or by customers they manage", verb)
 	case "customer":
-		// Customers can only delete systems they created themselves
-		if creator.OrganizationID == userOrgID {
+		if ownerOrgID == userOrgID {
 			return true, ""
 		}
-		return false, "customers can only delete systems created by their own organization"
+		return false, fmt.Sprintf("customers can only %s systems owned by their own organization", verb)
 	default:
-		return false, "insufficient permissions to delete systems"
+		return false, fmt.Sprintf("insufficient permissions to %s systems", verb)
 	}
 }
 
-// CanAccessSystem validates if a user can access a specific system based on created_by organization
+// CanAccessSystem validates if a user can read a specific system. Gates on the
+// current owning organization, NOT the creator: a reassigned system follows
+// the new owner.
 func (s *LocalSystemsService) CanAccessSystem(system *models.System, userOrgRole, userOrgID string) (bool, string) {
-	// Normalize role to lowercase for case-insensitive comparison
-	normalizedRole := strings.ToLower(userOrgRole)
+	return s.canActOnSystem(system, userOrgRole, userOrgID, "access")
+}
 
-	switch normalizedRole {
-	case "owner":
-		return true, ""
-	case "distributor":
-		// Distributor can access systems created by organizations they manage hierarchically
-		userService := NewUserService()
-		if userService.IsOrganizationInHierarchy(normalizedRole, userOrgID, system.CreatedBy.OrganizationID) {
-			return true, ""
-		}
-		return false, "distributors can only access systems created by organizations they manage"
-	case "reseller":
-		// Reseller can access systems created by their own organization
-		if system.CreatedBy.OrganizationID == userOrgID {
-			return true, ""
-		}
-		return false, "resellers can only access systems created by their own organization"
-	case "customer":
-		// Customers can access systems created by their own organization
-		if system.CreatedBy.OrganizationID == userOrgID {
-			return true, ""
-		}
-		return false, "customers can only access systems created by their own organization"
-	default:
-		return false, "insufficient permissions to access systems"
-	}
+// CanUpdateSystem validates if a user can update a system. Same gating as
+// CanAccessSystem (current owner-based).
+func (s *LocalSystemsService) CanUpdateSystem(system *models.System, userOrgRole, userOrgID string) (bool, string) {
+	return s.canActOnSystem(system, userOrgRole, userOrgID, "update")
+}
+
+// CanDeleteSystem validates if a user can delete a system. Same gating as
+// CanAccessSystem (current owner-based).
+func (s *LocalSystemsService) CanDeleteSystem(system *models.System, userOrgRole, userOrgID string) (bool, string) {
+	return s.canActOnSystem(system, userOrgRole, userOrgID, "delete")
 }
 
 // =============================================================================
@@ -955,7 +1088,9 @@ func (s *LocalSystemsService) GetTotalsByCreatedByOrganizations(allowedOrgIDs []
 	orgClause := ""
 	if allowedOrgIDs != nil {
 		args = append(args, pq.Array(allowedOrgIDs))
-		orgClause = " AND s.created_by ->> 'organization_id' = ANY($2::text[])"
+		// Filter on current owning organization, not creator: a reassigned
+		// system must contribute to the new owner's totals.
+		orgClause = " AND s.organization_id = ANY($2::text[])"
 	}
 
 	query := fmt.Sprintf(`
@@ -991,7 +1126,15 @@ func (s *LocalSystemsService) CanCreateSystemForOrganization(userOrgRole, userOr
 
 	switch normalizedOrgRole {
 	case "owner":
-		return true, ""
+		// Owner can assign to any organization in the hierarchy. We still
+		// gate the assignment on existence so a typo or stale logto_id can't
+		// strand a system under a phantom organization (which would silently
+		// route its backups to an unreachable S3 prefix on reassignment).
+		userService := NewUserService()
+		if userService.IsOrganizationInHierarchy(normalizedOrgRole, userOrgID, targetOrgID) {
+			return true, ""
+		}
+		return false, "target organization does not exist"
 	case "distributor":
 		// Distributor can create systems for organizations they manage hierarchically
 		userService := NewUserService()
@@ -1118,7 +1261,7 @@ func (s *LocalSystemsService) GetSystemsTrend(period int, userOrgRole, userOrgID
 					(SELECT COUNT(*)
 					 FROM systems
 					 WHERE deleted_at IS NULL
-					   AND created_by ->> 'organization_id' IN (%s)
+					   AND organization_id IN (%s)
 					   AND created_at <= ds.date + INTERVAL '23 hours 59 minutes 59 seconds'),
 					0
 				) AS count
@@ -1140,7 +1283,7 @@ func (s *LocalSystemsService) GetSystemsTrend(period int, userOrgRole, userOrgID
 					(SELECT COUNT(*)
 					 FROM systems
 					 WHERE deleted_at IS NULL
-					   AND created_by ->> 'organization_id' IN (%s)
+					   AND organization_id IN (%s)
 					   AND created_at <= ws.week_start + INTERVAL '6 days 23 hours 59 minutes 59 seconds'),
 					0
 				) AS count
@@ -1162,7 +1305,7 @@ func (s *LocalSystemsService) GetSystemsTrend(period int, userOrgRole, userOrgID
 					(SELECT COUNT(*)
 					 FROM systems
 					 WHERE deleted_at IS NULL
-					   AND created_by ->> 'organization_id' IN (%s)
+					   AND organization_id IN (%s)
 					   AND created_at <= ms.month_start + INTERVAL '1 month' - INTERVAL '1 second'),
 					0
 				) AS count
diff --git a/backend/storage/backups.go b/backend/storage/backups.go
new file mode 100644
index 000000000..73492ddb1
--- /dev/null
+++ b/backend/storage/backups.go
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2026 Nethesis S.r.l.
+ * http://www.nethesis.it - info@nethesis.it
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * author: Edoardo Spadoni <edoardo.spadoni@nethesis.it>
+ */
+
+package storage
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/service/s3"
+	s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+
+	"github.com/nethesis/my/backend/configuration"
+	"github.com/nethesis/my/backend/logger"
+)
+
+// CopyBackupPrefix copies every backup object stored under
+// {fromOrgID}/{systemKey}/ to {toOrgID}/{systemKey}/, preserving the per-object
+// metadata. CopyObject is idempotent (it overwrites the destination), so a
+// retry after a partial failure safely resumes the copy. The function does
+// not delete source objects — callers run DeleteBackupPrefix only after the
+// authoritative organization_id flip has been committed in the database.
+//
+// A no-op when the backup storage client is not configured (dev environments
+// without BACKUP_S3_ENDPOINT): the system has no backups to migrate anywhere.
+func CopyBackupPrefix(ctx context.Context, fromOrgID, toOrgID, systemKey string) (int, error) {
+	if fromOrgID == toOrgID {
+		return 0, nil
+	}
+
+	client, _, err := BackupClient(ctx)
+	if err != nil {
+		logger.Warn().Err(err).Str("system_key", systemKey).Msg("backup storage unavailable; skipping prefix copy")
+		return 0, nil
+	}
+
+	bucket := configuration.Config.BackupS3Bucket
+	srcPrefix := fmt.Sprintf("%s/%s/", fromOrgID, systemKey)
+	dstPrefix := fmt.Sprintf("%s/%s/", toOrgID, systemKey)
+
+	copied := 0
+	paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
+		Bucket: aws.String(bucket),
+		Prefix: aws.String(srcPrefix),
+	})
+	for paginator.HasMorePages() {
+		page, err := paginator.NextPage(ctx)
+		if err != nil {
+			return copied, fmt.Errorf("list source backups: %w", err)
+		}
+		for _, o := range page.Contents {
+			srcKey := aws.ToString(o.Key)
+			// Trim only the source prefix and append it to the destination
+			// prefix so the trailing UUIDv7 + extension is preserved verbatim.
+			dstKey := dstPrefix + srcKey[len(srcPrefix):]
+
+			// CopySource expects "{bucket}/{url-escaped-key}". The key may
+			// contain characters AWS treats as path separators if not escaped.
+			copySource := bucket + "/" + url.PathEscape(srcKey)
+
+			_, err := client.CopyObject(ctx, &s3.CopyObjectInput{
+				Bucket:            aws.String(bucket),
+				Key:               aws.String(dstKey),
+				CopySource:        aws.String(copySource),
+				MetadataDirective: "COPY",
+				// Re-assert SSE on the destination so the copy doesn't
+				// silently land unencrypted if the bucket-default policy
+				// drifts. The ingest path on collect always sets AES256;
+				// matching it here keeps every object on the bucket
+				// uniformly encrypted.
+				ServerSideEncryption: s3types.ServerSideEncryptionAes256,
+			})
+			if err != nil {
+				return copied, fmt.Errorf("copy %s -> %s: %w", srcKey, dstKey, err)
+			}
+			copied++
+		}
+	}
+
+	logger.Info().
+		Str("system_key", systemKey).
+		Str("from_org_id", fromOrgID).
+		Str("to_org_id", toOrgID).
+		Int("objects_copied", copied).
+		Msg("backup prefix copied across organizations")
+	return copied, nil
+}
+
+// DeleteBackupPrefix removes every object stored under {orgID}/{systemKey}/.
+// Used as a best-effort cleanup after a successful CopyBackupPrefix +
+// organization_id flip: the destination prefix is now authoritative and the
+// source bytes are safe to drop. A failure is recoverable (the orphan objects
+// still occupy the source organization's quota until a sweep removes them)
+// and must not surface as a request failure to the admin who triggered the
+// reassignment.
+//
+// A no-op when the backup storage client is not configured.
+func DeleteBackupPrefix(ctx context.Context, orgID, systemKey string) (int, error) {
+	client, _, err := BackupClient(ctx)
+	if err != nil {
+		logger.Warn().Err(err).Str("system_key", systemKey).Msg("backup storage unavailable; skipping prefix delete")
+		return 0, nil
+	}
+
+	bucket := configuration.Config.BackupS3Bucket
+	prefix := fmt.Sprintf("%s/%s/", orgID, systemKey)
+
+	deleted := 0
+	paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
+		Bucket: aws.String(bucket),
+		Prefix: aws.String(prefix),
+	})
+	for paginator.HasMorePages() {
+		page, err := paginator.NextPage(ctx)
+		if err != nil {
+			return deleted, fmt.Errorf("list backups for delete: %w", err)
+		}
+		for _, o := range page.Contents {
+			_, delErr := client.DeleteObject(ctx, &s3.DeleteObjectInput{
+				Bucket: aws.String(bucket),
+				Key:    o.Key,
+			})
+			if delErr != nil {
+				return deleted, fmt.Errorf("delete %s: %w", aws.ToString(o.Key), delErr)
+			}
+			deleted++
+		}
+	}
+
+	logger.Info().
+		Str("system_key", systemKey).
+		Str("org_id", orgID).
+		Int("objects_deleted", deleted).
+		Msg("backup prefix deleted")
+	return deleted, nil
+}
diff --git a/backend/storage/backups_integration_test.go b/backend/storage/backups_integration_test.go
new file mode 100644
index 000000000..d9e3aa63f
--- /dev/null
+++ b/backend/storage/backups_integration_test.go
@@ -0,0 +1,177 @@
+//go:build integration
+// +build integration
+
+/*
+ * Copyright (C) 2026 Nethesis S.r.l.
+ * http://www.nethesis.it - info@nethesis.it
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * author: Edoardo Spadoni <edoardo.spadoni@nethesis.it>
+ */
+
+package storage
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/service/s3"
+	s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/nethesis/my/backend/configuration"
+)
+
+// TestCopyAndDeleteBackupPrefix exercises the helpers used by the
+// org-reassignment flow against a live S3-compatible endpoint. It seeds three
+// objects under {fromOrg}/{systemKey}/, copies them to {toOrg}/{systemKey}/,
+// asserts the destination matches, runs the copy a second time to confirm
+// idempotency, then deletes the source prefix.
+//
+// Skipped unless BACKUP_S3_* env vars are set; not part of the default CI
+// pipeline. Run with `go test -tags=integration ./storage/...` after pointing
+// the BACKUP_S3_* env vars at any reachable bucket (MinIO, DO Spaces, R2, AWS).
+func TestCopyAndDeleteBackupPrefix(t *testing.T) {
+	endpoint := os.Getenv("BACKUP_S3_ENDPOINT")
+	bucket := os.Getenv("BACKUP_S3_BUCKET")
+	if endpoint == "" || bucket == "" {
+		t.Skip("BACKUP_S3_* env vars not set; skipping integration test")
+	}
+
+	configuration.Config.BackupS3Endpoint = endpoint
+	configuration.Config.BackupS3Bucket = bucket
+	configuration.Config.BackupS3AccessKey = os.Getenv("BACKUP_S3_ACCESS_KEY")
+	configuration.Config.BackupS3SecretKey = os.Getenv("BACKUP_S3_SECRET_KEY")
+	configuration.Config.BackupS3Region = os.Getenv("BACKUP_S3_REGION")
+	if configuration.Config.BackupS3Region == "" {
+		configuration.Config.BackupS3Region = "us-east-1"
+	}
+	if v := os.Getenv("BACKUP_S3_USE_PATH_STYLE"); v == "true" {
+		configuration.Config.BackupS3UsePathStyle = true
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+	defer cancel()
+
+	client, _, err := BackupClient(ctx)
+	require.NoError(t, err, "backup client init should succeed once env vars are set")
+
+	systemKey := fmt.Sprintf("integration-test-NETH-%d", time.Now().UnixNano())
+	fromOrg := "org-source-" + systemKey
+	toOrg := "org-target-" + systemKey
+
+	srcPrefix := fmt.Sprintf("%s/%s/", fromOrg, systemKey)
+	dstPrefix := fmt.Sprintf("%s/%s/", toOrg, systemKey)
+
+	// Seed: three objects under the source prefix with realistic metadata.
+	seedKeys := []string{
+		srcPrefix + "01934fab-bc33-7890-a1b2-c3d4e5f6a701.tar.gz",
+		srcPrefix + "01934fab-bc33-7890-a1b2-c3d4e5f6a702.tar.xz",
+		srcPrefix + "01934fab-bc33-7890-a1b2-c3d4e5f6a703.gpg",
+	}
+	for i, k := range seedKeys {
+		body := []byte(fmt.Sprintf("test backup payload %d", i))
+		_, err := client.PutObject(ctx, &s3.PutObjectInput{
+			Bucket:               aws.String(bucket),
+			Key:                  aws.String(k),
+			Body:                 bytes.NewReader(body),
+			ContentLength:        aws.Int64(int64(len(body))),
+			ContentType:          aws.String("application/octet-stream"),
+			Metadata:             map[string]string{"sha256": fmt.Sprintf("seed-%d", i), "filename": "seeded.bin"},
+			ServerSideEncryption: s3types.ServerSideEncryptionAes256,
+		})
+		require.NoError(t, err, "seed put for %s", k)
+	}
+
+	// Best-effort cleanup on any exit path.
+	defer func() {
+		_, _ = DeleteBackupPrefix(context.Background(), fromOrg, systemKey)
+		_, _ = DeleteBackupPrefix(context.Background(), toOrg, systemKey)
+	}()
+
+	t.Run("copy", func(t *testing.T) {
+		copied, err := CopyBackupPrefix(ctx, fromOrg, toOrg, systemKey)
+		require.NoError(t, err)
+		assert.Equal(t, 3, copied)
+
+		dstObjs := listKeys(t, ctx, client, bucket, dstPrefix)
+		assert.Len(t, dstObjs, 3)
+
+		// Each destination key must mirror the source name byte-for-byte
+		// after the prefix swap, so backup IDs survive the migration.
+		expected := map[string]bool{
+			dstPrefix + "01934fab-bc33-7890-a1b2-c3d4e5f6a701.tar.gz": true,
+			dstPrefix + "01934fab-bc33-7890-a1b2-c3d4e5f6a702.tar.xz": true,
+			dstPrefix + "01934fab-bc33-7890-a1b2-c3d4e5f6a703.gpg":    true,
+		}
+		for _, k := range dstObjs {
+			assert.True(t, expected[k], "unexpected destination key %s", k)
+		}
+
+		// Spot-check that the payload survived intact.
+		got, err := client.GetObject(ctx, &s3.GetObjectInput{
+			Bucket: aws.String(bucket),
+			Key:    aws.String(dstPrefix + "01934fab-bc33-7890-a1b2-c3d4e5f6a701.tar.gz"),
+		})
+		require.NoError(t, err)
+		body, err := io.ReadAll(got.Body)
+		require.NoError(t, err)
+		_ = got.Body.Close()
+		assert.Equal(t, "test backup payload 0", string(body))
+	})
+
+	t.Run("copy_is_idempotent", func(t *testing.T) {
+		copied, err := CopyBackupPrefix(ctx, fromOrg, toOrg, systemKey)
+		require.NoError(t, err)
+		// Second run still copies all source objects (CopyObject overwrites).
+		// What matters is the destination object count is unchanged.
+		assert.Equal(t, 3, copied)
+		assert.Len(t, listKeys(t, ctx, client, bucket, dstPrefix), 3)
+	})
+
+	t.Run("delete_source", func(t *testing.T) {
+		deleted, err := DeleteBackupPrefix(ctx, fromOrg, systemKey)
+		require.NoError(t, err)
+		assert.Equal(t, 3, deleted)
+		assert.Empty(t, listKeys(t, ctx, client, bucket, srcPrefix))
+		// Destination is still intact after the source-side cleanup.
+		assert.Len(t, listKeys(t, ctx, client, bucket, dstPrefix), 3)
+	})
+
+	t.Run("delete_is_idempotent", func(t *testing.T) {
+		deleted, err := DeleteBackupPrefix(ctx, fromOrg, systemKey)
+		require.NoError(t, err)
+		assert.Equal(t, 0, deleted)
+	})
+
+	t.Run("same_org_is_no_op", func(t *testing.T) {
+		copied, err := CopyBackupPrefix(ctx, toOrg, toOrg, systemKey)
+		require.NoError(t, err)
+		assert.Equal(t, 0, copied)
+	})
+}
+
+func listKeys(t *testing.T, ctx context.Context, client *s3.Client, bucket, prefix string) []string {
+	t.Helper()
+	out := []string{}
+	paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
+		Bucket: aws.String(bucket),
+		Prefix: aws.String(prefix),
+	})
+	for paginator.HasMorePages() {
+		page, err := paginator.NextPage(ctx)
+		require.NoError(t, err)
+		for _, o := range page.Contents {
+			out = append(out, aws.ToString(o.Key))
+		}
+	}
+	return out
+}
diff --git a/backend/storage/s3.go b/backend/storage/s3.go
new file mode 100644
index 000000000..bd8f21e14
--- /dev/null
+++ b/backend/storage/s3.go
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2026 Nethesis S.r.l.
+ * http://www.nethesis.it - info@nethesis.it
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * author: Edoardo Spadoni <edoardo.spadoni@nethesis.it>
+ */
+
+// Package storage provides an S3 client for the DigitalOcean Spaces bucket
+// that holds appliance configuration backups.
+//
+// The backend consumes the bucket read-only: it lists backups for a system,
+// fetches object metadata for rich UI rendering, and generates short-lived
+// presigned URLs so user browsers can download objects directly from Spaces
+// without streaming through the API.
+//
+// When BACKUP_S3_PRESIGN_ENDPOINT is set, the presigner is built on a
+// separate client that signs URLs with that endpoint instead of the one
+// used for server-side calls. This matters only for local development
+// where backend runs inside a compose network and talks to MinIO over the
+// internal hostname, while the browser can only reach MinIO via a host
+// port mapping; using two endpoints keeps signatures valid in both worlds.
+package storage
+
+import (
+	"context"
+	"fmt"
+	"sync"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	awsconfig "github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/credentials"
+	"github.com/aws/aws-sdk-go-v2/service/s3"
+
+	"github.com/nethesis/my/backend/configuration"
+)
+
+var (
+	backupClient     *s3.Client
+	backupPresigner  *s3.PresignClient
+	backupClientErr  error
+	backupClientOnce sync.Once
+)
+
+// BackupClient returns a singleton S3 client + presigner wired to the
+// configured backup store.
+func BackupClient(ctx context.Context) (*s3.Client, *s3.PresignClient, error) {
+	backupClientOnce.Do(func() {
+		backupClient, backupPresigner, backupClientErr = buildBackupClient(ctx)
+	})
+	return backupClient, backupPresigner, backupClientErr
+}
+
+func buildBackupClient(ctx context.Context) (*s3.Client, *s3.PresignClient, error) {
+	if configuration.Config.BackupS3Endpoint == "" {
+		return nil, nil, fmt.Errorf("BACKUP_S3_ENDPOINT is not set")
+	}
+	if configuration.Config.BackupS3AccessKey == "" || configuration.Config.BackupS3SecretKey == "" {
+		return nil, nil, fmt.Errorf("BACKUP_S3_ACCESS_KEY and BACKUP_S3_SECRET_KEY must be set")
+	}
+
+	creds := credentials.NewStaticCredentialsProvider(
+		configuration.Config.BackupS3AccessKey,
+		configuration.Config.BackupS3SecretKey,
+		"",
+	)
+
+	cfg, err := awsconfig.LoadDefaultConfig(ctx,
+		awsconfig.WithRegion(configuration.Config.BackupS3Region),
+		awsconfig.WithCredentialsProvider(creds),
+	)
+	if err != nil {
+		return nil, nil, fmt.Errorf("load S3 config: %w", err)
+	}
+
+	client := s3.NewFromConfig(cfg, func(o *s3.Options) {
+		o.BaseEndpoint = aws.String(configuration.Config.BackupS3Endpoint)
+		o.UsePathStyle = configuration.Config.BackupS3UsePathStyle
+	})
+
+	presignClient := client
+	if override := configuration.Config.BackupS3PresignEndpoint; override != "" {
+		presignClient = s3.NewFromConfig(cfg, func(o *s3.Options) {
+			o.BaseEndpoint = aws.String(override)
+			o.UsePathStyle = configuration.Config.BackupS3UsePathStyle
+		})
+	}
+	presigner := s3.NewPresignClient(presignClient)
+
+	return client, presigner, nil
+}
diff --git a/collect/.env.example b/collect/.env.example
index fb70da258..d84e17ae9 100644
--- a/collect/.env.example
+++ b/collect/.env.example
@@ -77,6 +77,36 @@ REDIS_URL=redis://localhost:6379
 # Heartbeat monitoring configuration
 #HEARTBEAT_TIMEOUT_MINUTES=10
 
+# Backup storage (DigitalOcean Spaces — shared S3 account with Mimir)
+# Endpoint, access key, and secret key are the same values configured for
+# Mimir (MIMIR_S3_*). Only the bucket differs — a dedicated bucket for
+# appliance configuration backups.
+#BACKUP_S3_ENDPOINT=https://ams3.digitaloceanspaces.com
+# Region value used in SigV4 signing. For DO Spaces use the region code
+# (ams3, nyc3, fra1, …). For AWS S3 use the target region (eu-west-1, …).
+# For most emulators (MinIO, Garage) any non-empty value works; the code
+# falls back to "us-east-1" when unset.
+#BACKUP_S3_REGION=ams3
+#BACKUP_S3_BUCKET=my-backups-dev
+#BACKUP_S3_ACCESS_KEY=
+#BACKUP_S3_SECRET_KEY=
+# Set to "true" only against local S3 emulators that require path-style
+# addressing (e.g. MinIO or Garage for development). Spaces uses virtual-
+# hosted style (default false).
+#BACKUP_S3_USE_PATH_STYLE=false
+
+# Backup ingest limits
+#BACKUP_MAX_UPLOAD_SIZE=2147483648      # 2 GB per upload
+#BACKUP_MAX_PER_SYSTEM=10               # number of backups retained per system
+#BACKUP_MAX_SIZE_PER_SYSTEM=524288000   # 500 MB total per system
+#BACKUP_MAX_SIZE_PER_ORG=107374182400   # 100 GB aggregate per organization; set to 0 to disable (logged at startup)
+
+# Per-system ingest rate limits (Redis-backed, fail-open on Redis outage).
+# Appliances upload on a daily timer, so these numbers are generous —
+# their job is to block flood-style abuse. Set either to 0 to disable.
+#BACKUP_RATE_LIMIT_PER_MINUTE=6
+#BACKUP_RATE_LIMIT_PER_HOUR=60
+
 # Batch processing configuration
 #BATCH_PROCESSOR_SIZE=100
 #BATCH_PROCESSOR_TIMEOUT=30s
@@ -89,7 +119,10 @@ REDIS_URL=redis://localhost:6379
 # Mimir metrics storage
 #MIMIR_URL=http://localhost:9009
 
-# Alerting history webhook authentication (shared secret with backend's ALERTING_HISTORY_WEBHOOK_SECRET)
+# Alerting history webhook authentication (shared secret with backend's
+# ALERTING_HISTORY_WEBHOOK_SECRET). Must be at least 32 characters; the
+# value is rendered into the Alertmanager YAML pushed to Mimir, so a short
+# secret can be cheaply brute-forced from any read of the Mimir bucket.
 #ALERTING_HISTORY_WEBHOOK_SECRET=
 
 # Logging configuration
diff --git a/collect/.render-build-trigger b/collect/.render-build-trigger
index 4ee864ad3..320733ced 100644
--- a/collect/.render-build-trigger
+++ b/collect/.render-build-trigger
@@ -2,9 +2,9 @@
 # This file is used to force Docker service rebuilds in PR previews
 # Modify LAST_UPDATE to trigger rebuilds
 
-LAST_UPDATE=2026-04-07T13:40:58Z
+LAST_UPDATE=2026-04-22T07:15:29Z
 
 # Instructions:
 # 1. To force rebuild of Docker services in a PR, update LAST_UPDATE
-# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-04-07T13:40:58Z/LAST_UPDATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)/" $f/.render-build-trigger
+# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-04-22T07:15:29Z
 # 2. Commit and push changes to trigger Docker rebuilds
diff --git a/collect/Makefile b/collect/Makefile
index 8357666c1..22669fbc9 100644
--- a/collect/Makefile
+++ b/collect/Makefile
@@ -74,6 +74,16 @@ test-coverage:
 	@go tool cover -html=coverage.out -o coverage.html
 	@echo "Coverage report generated: coverage.html"
 
+# Run the backup S3 integration test against a real endpoint. The test
+# exercises PutObject/CopyObject/HeadObject/ListObjectsV2/GetObject/
+# DeleteObject against the bucket pointed at by BACKUP_S3_* env vars.
+# It skips when those env vars are unset, so an accidental invocation
+# never mutates a production bucket.
+.PHONY: test-integration
+test-integration:
+	@echo "Running backup S3 integration test (requires BACKUP_S3_* env)..."
+	@go test -tags=integration -timeout 60s -v ./methods/ -run TestBackupStorageRoundTrip
+
 # Run linting
 .PHONY: lint
 lint:
diff --git a/collect/README.md b/collect/README.md
index 9e1099927..6587d52ce 100644
--- a/collect/README.md
+++ b/collect/README.md
@@ -201,8 +201,105 @@ curl -X POST http://localhost:8081/api/systems/inventory \
   -H "Content-Type: application/json" \
   -u "system_key:system_secret" \
   -d '{"system_id": "test", "timestamp": "2025-07-13T10:00:00Z", "data": {"os": {"name": "TestOS"}}}'
+
+# Upload a configuration backup (streams the file body; GPG encryption is
+# up to the appliance). X-Filename is the user-facing name shown in the UI.
+curl -X POST http://localhost:8081/api/systems/backups \
+  -u "system_key:system_secret" \
+  -H "Content-Type: application/octet-stream" \
+  -H "X-Filename: dump.json.gz.gpg" \
+  --data-binary @/path/to/backup.gpg
+```
+
+### Appliance integration (NS8 / NethSecurity)
+
+Each authenticated system owns a prefix in the backup bucket
+(`{org_id}/{system_key}/...`) and the three endpoints below are all the
+appliance needs. Auth is always HTTP Basic with the system_key and the
+system_secret returned at registration:
+
+| Endpoint | Purpose |
+|----------|---------|
+| `POST /api/systems/backups`   | Stream a new backup. 201 on success with `{id, sha256, size, uploaded_at}` in the body. |
+| `GET /api/systems/backups`    | List own backups with metadata. |
+| `GET /api/systems/backups/:id` | Download a stored backup for restore. |
+
+Retention is enforced server-side: after each upload the oldest entries
+beyond `BACKUP_MAX_PER_SYSTEM` or `BACKUP_MAX_SIZE_PER_SYSTEM` are
+pruned. Client-side dedup via local MD5 is still useful — if the hash
+of the just-encrypted blob equals the one from the previous run the
+appliance can skip the upload entirely.
+
+### Backup storage
+
+Backup objects are stored on any S3-compatible bucket. In production
+the project targets DigitalOcean Spaces (the same account that backs
+Mimir); for local development point the `BACKUP_S3_*` env vars at your
+preferred emulator (MinIO, Garage, LocalStack, …) or at a dedicated dev
+bucket on your S3 provider of choice. Backend and collect must share
+the same bucket — collect writes, backend reads and issues presigned
+download URLs.
+
+**Separate credentials (optional).** Nothing in the code forces backend
+and collect to share the same `BACKUP_S3_ACCESS_KEY` / `BACKUP_S3_SECRET_KEY`.
+If your S3 provider supports per-key policies (AWS S3 IAM, Cloudflare
+R2 scoped tokens, MinIO user policies), issue two keys: one for
+**collect** scoped to `PutObject`/`CopyObject`/`DeleteObject`/
+`ListObjectsV2`/`HeadObject` on the bucket prefix, and one for
+**backend** scoped to `GetObject`/`ListObjectsV2`/`HeadObject`/
+`DeleteObject`. Then set a different `BACKUP_S3_ACCESS_KEY` /
+`BACKUP_S3_SECRET_KEY` pair on each service. This contains blast
+radius if one component is ever compromised.
+
+Object keys follow `{org_id}/{system_key}/{backup_id}.{ext}` — the
+user-facing `system_key` (`NETH-…`) is preferred over the internal UUID
+so operators browsing a raw bucket listing can recognise each system at
+a glance. Per-object
+metadata is stored as standard `x-amz-meta-*` headers:
+
+| Header                   | Source                                                               |
+|--------------------------|----------------------------------------------------------------------|
+| `x-amz-meta-sha256`      | Computed by collect via streaming tee at ingest                      |
+| `x-amz-meta-filename`    | From the appliance request header `X-Filename` (user-facing name)    |
+| `x-amz-meta-uploader-ip` | Client IP observed by collect                                        |
+
+### Testing the backup round-trip end-to-end
+
+Any S3-compatible bucket works. Provision one on your preferred
+provider (DigitalOcean Spaces, Cloudflare R2, AWS S3, a self-hosted
+MinIO/Garage, …), issue an access key pair scoped to that bucket, and
+set the values in `backend/.env` and `collect/.env`:
+
+```
+BACKUP_S3_ENDPOINT=...        # e.g. https://ams3.digitaloceanspaces.com
+BACKUP_S3_REGION=ams3         # DO Spaces region code; any non-empty value works for MinIO/Garage
+BACKUP_S3_BUCKET=my-backups-dev
+BACKUP_S3_ACCESS_KEY=...
+BACKUP_S3_SECRET_KEY=...
+BACKUP_S3_USE_PATH_STYLE=false   # set to true only for MinIO/Garage
 ```
 
+Restart backend and collect, then simulate an appliance upload — the
+GPG pipeline below matches exactly what NS8's `send-cluster-backup`
+and NethSecurity's `send-backup` produce today:
+
+```bash
+# Prepare a fake GPG-encrypted blob
+echo '{"simulation": true}' | gzip > /tmp/dump.json.gz
+gpg --batch --yes -c --pinentry-mode loopback --cipher-algo AES256 \
+    --passphrase-fd 0 /tmp/dump.json.gz <<< "local-dev-passphrase"
+
+# Upload as an authenticated appliance
+curl --user "$SYSTEM_KEY:$SYSTEM_SECRET" \
+     -H "X-Filename: dump.json.gz.gpg" \
+     -H "Content-Type: application/octet-stream" \
+     --data-binary @/tmp/dump.json.gz.gpg \
+     http://localhost:8081/api/systems/backups
+```
+
+Verify the object shows up under `{org_id}/{system_key}/<uuidv7>.gpg`
+in the bucket (via the provider's console or the AWS CLI).
+
 ## Project Structure
 
 ```
@@ -218,6 +315,7 @@ collect/
 ├── middleware/            # Authentication middleware
 ├── models/                # Data structures
 ├── queue/                 # Redis queue management
+├── storage/               # S3 client for backup ingest
 ├── workers/               # Background processing workers
 │   ├── inventory_worker.go        # Batch inventory processing
 │   ├── diff_worker.go             # Change detection
diff --git a/collect/configuration/configuration.go b/collect/configuration/configuration.go
index d101a378f..fceb87e88 100644
--- a/collect/configuration/configuration.go
+++ b/collect/configuration/configuration.go
@@ -11,6 +11,7 @@ package configuration
 
 import (
 	"fmt"
+	"net/url"
 	"os"
 	"strconv"
 	"time"
@@ -86,6 +87,34 @@ type Configuration struct {
 
 	// Alertmanager webhook authentication
 	AlertmanagerWebhookSecret string `json:"alertmanager_webhook_secret"`
+
+	// Cross-service plumbing — must match backend's APP_ENV / INTERNAL_HMAC_SECRET.
+	AppEnv             string `json:"app_env"`
+	InternalHMACSecret string `json:"internal_hmac_secret"`
+
+	// Backup storage — S3 client credentials used to reach the DigitalOcean
+	// Spaces bucket that holds appliance configuration backups. The same
+	// Spaces account also hosts the Mimir buckets; values for endpoint,
+	// access key, and secret key are the shared S3 credentials.
+	BackupS3Endpoint       string `json:"backup_s3_endpoint"`
+	BackupS3Region         string `json:"backup_s3_region"`
+	BackupS3Bucket         string `json:"backup_s3_bucket"`
+	BackupS3AccessKey      string `json:"backup_s3_access_key"`
+	BackupS3SecretKey      string `json:"backup_s3_secret_key"`
+	BackupS3UsePathStyle   bool   `json:"backup_s3_use_path_style"`
+	BackupMaxUploadSize    int64  `json:"backup_max_upload_size"`
+	BackupMaxPerSystem     int    `json:"backup_max_per_system"`
+	BackupMaxSizePerSystem int64  `json:"backup_max_size_per_system"`
+	// Aggregate cap across every system owned by the same
+	// organization. 0 disables the check. Useful to contain blast
+	// radius when a single org's credentials are compromised.
+	BackupMaxSizePerOrg int64 `json:"backup_max_size_per_org"`
+	// Ingest rate limits (per system_id, Redis-backed). Legitimate
+	// appliances upload on a daily timer, so these numbers are set
+	// generously — their job is to block flood-style abuse, not to
+	// shape normal traffic.
+	BackupRateLimitPerMinute int `json:"backup_rate_limit_per_minute"`
+	BackupRateLimitPerHour   int `json:"backup_rate_limit_per_hour"`
 }
 
 var Config = Configuration{}
@@ -152,7 +181,12 @@ func Init() {
 
 	// System authentication configuration
 	Config.SystemSecretMinLength = parseIntWithDefault("SYSTEM_SECRET_MIN_LENGTH", 32)
-	Config.SystemAuthCacheTTL = parseDurationWithDefault("SYSTEM_AUTH_CACHE_TTL", 24*time.Hour)
+	// Cache positive auth results for 10 minutes by default. Shorter
+	// than a day so that credential rotations and system deletions
+	// done on the backend propagate to collect within that window
+	// without a dedicated invalidation bus. Each cache miss falls
+	// through to Redis and then to Postgres.
+	Config.SystemAuthCacheTTL = parseDurationWithDefault("SYSTEM_AUTH_CACHE_TTL", 10*time.Minute)
 
 	// API configuration
 	Config.APIMaxRequestSize = parseInt64WithDefault("API_MAX_REQUEST_SIZE", 10*1024*1024) // 10MB
@@ -174,13 +208,100 @@ func Init() {
 		Config.MimirURL = "http://localhost:9009"
 	}
 
-	// Alerting history webhook authentication
+	// Alerting history webhook authentication. Refuse weak secrets at boot:
+	// the secret is also pushed into Mimir's Alertmanager YAML so anyone
+	// with read on the Mimir S3 bucket recovers it; a short value reduces
+	// brute-force resistance to nothing. An empty value is permitted (the
+	// webhook receiver returns 503 in that case) for dev configurations
+	// that don't run Alertmanager locally.
 	Config.AlertmanagerWebhookSecret = os.Getenv("ALERTING_HISTORY_WEBHOOK_SECRET")
+	if Config.AlertmanagerWebhookSecret != "" && len(Config.AlertmanagerWebhookSecret) < 32 {
+		logger.Fatal().
+			Int("length", len(Config.AlertmanagerWebhookSecret)).
+			Msg("ALERTING_HISTORY_WEBHOOK_SECRET must be at least 32 characters; refusing to start with a weak secret")
+	}
+
+	// Internal cross-service plumbing — must match backend's settings.
+	Config.AppEnv = getStringWithDefault("APP_ENV", "dev")
+	Config.InternalHMACSecret = os.Getenv("INTERNAL_HMAC_SECRET")
+	if Config.InternalHMACSecret != "" && len(Config.InternalHMACSecret) < 32 {
+		logger.Fatal().
+			Int("length", len(Config.InternalHMACSecret)).
+			Msg("INTERNAL_HMAC_SECRET must be at least 32 characters; refusing to start with a weak secret")
+	}
+
+	// Backup storage — S3 client credentials (DigitalOcean Spaces)
+	Config.BackupS3Endpoint = validateBackupEndpoint("BACKUP_S3_ENDPOINT", os.Getenv("BACKUP_S3_ENDPOINT"))
+	Config.BackupS3Region = getStringWithDefault("BACKUP_S3_REGION", "us-east-1")
+	Config.BackupS3Bucket = os.Getenv("BACKUP_S3_BUCKET")
+	Config.BackupS3AccessKey = os.Getenv("BACKUP_S3_ACCESS_KEY")
+	Config.BackupS3SecretKey = os.Getenv("BACKUP_S3_SECRET_KEY")
+	Config.BackupS3UsePathStyle = parseBoolWithDefault("BACKUP_S3_USE_PATH_STYLE", false)
+	Config.BackupMaxUploadSize = parseInt64WithDefault("BACKUP_MAX_UPLOAD_SIZE", 2*1024*1024*1024)
+	Config.BackupMaxPerSystem = parseIntWithDefault("BACKUP_MAX_PER_SYSTEM", 10)
+	Config.BackupMaxSizePerSystem = parseInt64WithDefault("BACKUP_MAX_SIZE_PER_SYSTEM", 500*1024*1024)
+	// Default 100 GiB per organization. An unset env var would otherwise
+	// leave the aggregate quota disabled, making a compromised system_key
+	// capable of saturating the backing bucket on behalf of its org.
+	// Set BACKUP_MAX_SIZE_PER_ORG=0 explicitly to disable.
+	Config.BackupMaxSizePerOrg = parseInt64WithDefault("BACKUP_MAX_SIZE_PER_ORG", 100*1024*1024*1024)
+	if os.Getenv("BACKUP_MAX_SIZE_PER_ORG") == "0" {
+		logger.Warn().Msg("BACKUP_MAX_SIZE_PER_ORG=0: per-organization aggregate backup quota disabled")
+	}
+	Config.BackupRateLimitPerMinute = parseIntWithDefault("BACKUP_RATE_LIMIT_PER_MINUTE", 6)
+	Config.BackupRateLimitPerHour = parseIntWithDefault("BACKUP_RATE_LIMIT_PER_HOUR", 60)
 
 	// Log successful configuration load
 	logger.LogConfigLoad("env", "configuration", true, nil)
 }
 
+// validateBackupEndpoint refuses HTTP endpoints unless the host is a
+// well-known dev loopback name; misconfigured prod deployments would
+// otherwise send signed S3 traffic in plaintext. Empty values are
+// returned unchanged (the storage package surfaces a clearer error).
+func validateBackupEndpoint(name, raw string) string {
+	if raw == "" {
+		return ""
+	}
+	u, err := url.Parse(raw)
+	if err != nil || u.Host == "" {
+		logger.LogConfigLoad("env", name, false, fmt.Errorf("invalid URL %q", raw))
+		return ""
+	}
+	// Reject userinfo (https://attacker:pw@host) — the AWS SDK ignores it but
+	// some HTTP clients down the chain may not, and there is no legitimate
+	// reason to embed credentials in the bucket endpoint URL.
+	if u.User != nil {
+		logger.LogConfigLoad("env", name, false, fmt.Errorf("userinfo in endpoint URL is not allowed"))
+		return ""
+	}
+	// The endpoint must be the bucket service root, not a sub-path. The S3
+	// SDK appends `/<bucket>/<key>` to whatever is given, so a non-root path
+	// silently mis-targets traffic.
+	if u.Path != "" && u.Path != "/" {
+		logger.LogConfigLoad("env", name, false, fmt.Errorf("non-root path %q in endpoint URL is not allowed", u.Path))
+		return ""
+	}
+	if u.Scheme == "https" {
+		return raw
+	}
+	if u.Scheme == "http" {
+		host := u.Hostname()
+		// Allowlist exact loopback hosts only. Suffix-matching `.local` /
+		// `.localtest.me` would otherwise admit `evil.local` /
+		// `evil.localtest.me` shipped over plaintext.
+		if host == "localhost" || host == "127.0.0.1" || host == "::1" ||
+			host == "my.localtest.me" ||
+			os.Getenv("BACKUP_S3_ALLOW_INSECURE") == "true" {
+			return raw
+		}
+		logger.LogConfigLoad("env", name, false, fmt.Errorf("HTTP endpoint to non-loopback host %q rejected; set BACKUP_S3_ALLOW_INSECURE=true to override", host))
+		return ""
+	}
+	logger.LogConfigLoad("env", name, false, fmt.Errorf("unsupported scheme %q", u.Scheme))
+	return ""
+}
+
 // parseDurationWithDefault parses a duration from environment variable or returns default
 func parseDurationWithDefault(envVar string, defaultValue time.Duration) time.Duration {
 	envValue := os.Getenv(envVar)
@@ -248,3 +369,20 @@ func getStringWithDefault(envVar string, defaultValue string) string {
 	}
 	return defaultValue
 }
+
+// parseBoolWithDefault parses a boolean from an env var or returns the default.
+// Accepts the same set of literals as strconv.ParseBool ("1"/"true"/"True"/"t"
+// /"TRUE" for true; "0"/"false"/"False"/"f"/"FALSE" for false). Aligning on
+// this helper keeps backend and collect from disagreeing on the same env value
+// (e.g. "1" being true on backend but false on collect).
+func parseBoolWithDefault(envVar string, defaultValue bool) bool {
+	v := os.Getenv(envVar)
+	if v == "" {
+		return defaultValue
+	}
+	parsed, err := strconv.ParseBool(v)
+	if err != nil {
+		return defaultValue
+	}
+	return parsed
+}
diff --git a/collect/configuration/configuration_unit_test.go b/collect/configuration/configuration_unit_test.go
index cbeaf665e..6f92b3baa 100644
--- a/collect/configuration/configuration_unit_test.go
+++ b/collect/configuration/configuration_unit_test.go
@@ -102,7 +102,7 @@ func TestInit(t *testing.T) {
 	assert.Equal(t, 6*time.Hour, Config.InventoryCleanupInterval)
 	assert.Equal(t, 10, Config.InventoryDiffDepth)
 	assert.Equal(t, 32, Config.SystemSecretMinLength)
-	assert.Equal(t, 24*time.Hour, Config.SystemAuthCacheTTL)
+	assert.Equal(t, 10*time.Minute, Config.SystemAuthCacheTTL)
 	assert.Equal(t, int64(10*1024*1024), Config.APIMaxRequestSize)
 	assert.Equal(t, 30*time.Second, Config.APIRequestTimeout)
 	assert.Equal(t, 30*time.Second, Config.HealthCheckInterval)
diff --git a/collect/go.mod b/collect/go.mod
index 4dfb08fe8..116e8a360 100644
--- a/collect/go.mod
+++ b/collect/go.mod
@@ -4,6 +4,10 @@ go 1.25.0
 
 require (
 	github.com/DATA-DOG/go-sqlmock v1.5.2
+	github.com/aws/aws-sdk-go-v2 v1.41.5
+	github.com/aws/aws-sdk-go-v2/config v1.32.14
+	github.com/aws/aws-sdk-go-v2/credentials v1.19.14
+	github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
 	github.com/gin-contrib/cors v1.7.7
 	github.com/gin-contrib/gzip v1.2.6
 	github.com/gin-gonic/gin v1.12.0
@@ -19,6 +23,21 @@ require (
 )
 
 require (
+	github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
+	github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
+	github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
+	github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
+	github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
+	github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
+	github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
+	github.com/aws/smithy-go v1.24.2 // indirect
 	github.com/bytedance/gopkg v0.1.3 // indirect
 	github.com/bytedance/sonic v1.15.0 // indirect
 	github.com/bytedance/sonic/loader v0.5.0 // indirect
diff --git a/collect/go.sum b/collect/go.sum
index 132734053..d8532feb2 100644
--- a/collect/go.sum
+++ b/collect/go.sum
@@ -1,5 +1,43 @@
 github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
 github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
+github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
+github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
+github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
+github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
+github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
+github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
+github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
+github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
 github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
 github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
 github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -20,8 +58,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
-github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
-github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
 github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
 github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
 github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
@@ -38,8 +74,6 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
-github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
 github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
 github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -121,8 +155,6 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
 go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
 golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
 golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
-golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
 golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
 golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
 golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
@@ -130,8 +162,6 @@ golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
 golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
diff --git a/collect/main.go b/collect/main.go
index d1193fe1c..494e5da55 100644
--- a/collect/main.go
+++ b/collect/main.go
@@ -92,6 +92,10 @@ func main() {
 		logger.Fatal().Err(err).Msg("Failed to start worker manager")
 	}
 
+	// Subscribe to the cross-service auth invalidation bus so credential
+	// changes made on the backend propagate to this cache within seconds.
+	middleware.StartAuthInvalidator(ctx)
+
 	// Start heartbeat monitor cron job
 	heartbeatMonitor := cron.NewHeartbeatMonitor()
 	go heartbeatMonitor.Start(ctx)
@@ -150,6 +154,12 @@ func main() {
 		// Rebranding endpoints (system fetches its own rebranding config)
 		systemsGroup.GET("/rebranding", methods.GetSystemRebranding)
 		systemsGroup.GET("/rebranding/:product_id/:asset", methods.GetSystemRebrandingAsset)
+
+		// Backup ingest/list/restore — appliance uploads its own backups
+		systemsGroup.POST("/backups", methods.UploadBackup)
+		systemsGroup.GET("/backups", methods.ListBackups)
+		systemsGroup.GET("/backups/:id", methods.DownloadBackup)
+		systemsGroup.DELETE("/backups/:id", methods.DeleteBackup)
 	}
 
 	// ===========================================
@@ -178,13 +188,19 @@ func main() {
 		c.JSON(http.StatusNotFound, response.NotFound("api not found", nil))
 	})
 
-	// Setup graceful shutdown with timeouts to prevent slowloris attacks
+	// Setup graceful shutdown. Use ReadHeaderTimeout (slowloris guard on the
+	// request line + headers) instead of ReadTimeout / WriteTimeout, since the
+	// latter cap the entire request lifetime including body streaming and
+	// would truncate large backup uploads / downloads — a 2 GB upload at
+	// 50 Mbps takes ~5 minutes, far past WriteTimeout=60s. Body-size limits
+	// are enforced per-handler via http.MaxBytesReader; per-request lifetime
+	// is bounded by the gin context (cancelled on client disconnect) and the
+	// upstream nginx timeouts (relaxed to 600s for /collect/api/systems/backups).
 	srv := &http.Server{
-		Addr:         configuration.Config.ListenAddress,
-		Handler:      router,
-		ReadTimeout:  30 * time.Second,
-		WriteTimeout: 60 * time.Second,
-		IdleTimeout:  120 * time.Second,
+		Addr:              configuration.Config.ListenAddress,
+		Handler:           router,
+		ReadHeaderTimeout: 30 * time.Second,
+		IdleTimeout:       120 * time.Second,
 	}
 
 	// Start server in a goroutine
diff --git a/collect/methods/alertmanager.go b/collect/methods/alertmanager.go
index 5bf9f3f12..012d2b16c 100644
--- a/collect/methods/alertmanager.go
+++ b/collect/methods/alertmanager.go
@@ -29,6 +29,17 @@ import (
 // above expected and bounds the worst case.
 const alertHistoryRequestTimeout = 15 * time.Second
 
+// alertHistoryMaxBodyBytes caps the JSON body size for alertmanager webhook
+// payloads. Alertmanager sends JSON arrays of resolved alerts; legitimate
+// payloads are at most a few KB. Cap at 1 MiB so a holder of the webhook
+// secret cannot exhaust memory or DB connections by shipping a giant body.
+const alertHistoryMaxBodyBytes = 1 << 20 // 1 MiB
+
+// alertHistoryMaxAlertsPerPayload caps the number of alerts processed in a
+// single webhook payload. Each alert costs an extra DB lookup + insert, so
+// a long array would still be expensive even within the byte cap.
+const alertHistoryMaxAlertsPerPayload = 1000
+
 // zeroTime is Alertmanager's sentinel for "no end time" on firing alerts.
 var zeroTime = time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)
 
@@ -36,11 +47,20 @@ var zeroTime = time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC)
 // It persists resolved alerts from Alertmanager webhook payloads.
 // Firing alerts are ignored; only resolved alerts contain valid startsAt/endsAt.
 func ReceiveAlertHistory(c *gin.Context) {
+	c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, alertHistoryMaxBodyBytes)
 	var payload models.AlertmanagerWebhookPayload
 	if err := c.ShouldBindJSON(&payload); err != nil {
 		c.JSON(http.StatusBadRequest, response.BadRequest("invalid request body: "+err.Error(), nil))
 		return
 	}
+	if len(payload.Alerts) > alertHistoryMaxAlertsPerPayload {
+		logger.Warn().
+			Int("alerts_received", len(payload.Alerts)).
+			Int("alerts_max", alertHistoryMaxAlertsPerPayload).
+			Msg("alertmanager history: rejecting oversized payload")
+		c.JSON(http.StatusRequestEntityTooLarge, response.Error(http.StatusRequestEntityTooLarge, "too many alerts in payload", nil))
+		return
+	}
 
 	ctx, cancel := context.WithTimeout(c.Request.Context(), alertHistoryRequestTimeout)
 	defer cancel()
diff --git a/collect/methods/backups.go b/collect/methods/backups.go
new file mode 100644
index 000000000..ea6b5434a
--- /dev/null
+++ b/collect/methods/backups.go
@@ -0,0 +1,862 @@
+/*
+ * Copyright (C) 2026 Nethesis S.r.l.
+ * http://www.nethesis.it - info@nethesis.it
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * author: Edoardo Spadoni <edoardo.spadoni@nethesis.it>
+ */
+
+package methods
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+	"sync/atomic"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/service/s3"
+	s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+	"github.com/gin-gonic/gin"
+	"github.com/google/uuid"
+
+	"github.com/nethesis/my/collect/configuration"
+	"github.com/nethesis/my/collect/database"
+	"github.com/nethesis/my/collect/logger"
+	"github.com/nethesis/my/collect/queue"
+	"github.com/nethesis/my/collect/response"
+	"github.com/nethesis/my/collect/storage"
+)
+
+// backupIDPattern pins the shape of a valid backup identifier: a UUIDv7
+// (32 hex digits with the version nibble and variant enforced) plus one of
+// the extensions derived by extractExtension. Anything else — path
+// components, traversal tokens, URL-encoded slashes, unexpected suffixes —
+// is refused before it reaches the storage layer so the S3 key can never
+// reach outside the authenticated system's prefix.
+var backupIDPattern = regexp.MustCompile(
+	`^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(?:\.(?:tar\.gz|tar\.xz|tar\.bz2|tar\.zst|gpg|bin))?$`,
+)
+
+// isValidBackupID is the strict allowlist for backup IDs that arrive via
+// user-controlled path parameters.
+func isValidBackupID(id string) bool {
+	return backupIDPattern.MatchString(strings.ToLower(id))
+}
+
+// safeFilenameChar keeps filenames to a conservative subset so that no
+// header or UI reflection can escape its context via CR/LF, quote,
+// wildcard, or non-printable bytes. Appliance-provided filenames that
+// violate the set are replaced char-by-char with '_' rather than
+// rejected outright — a valid backup should not be dropped just because
+// the caller labelled it with Unicode or spaces.
+func safeFilenameChar(r rune) rune {
+	switch {
+	case r >= 'A' && r <= 'Z':
+	case r >= 'a' && r <= 'z':
+	case r >= '0' && r <= '9':
+	case r == '.' || r == '-' || r == '_':
+	default:
+		return '_'
+	}
+	return r
+}
+
+// sanitizeFilename strips path components, maps any byte outside the
+// safe allowlist to '_', and truncates to 255 chars so the final value
+// is safe to ship as an S3 metadata header and as an inline filename in
+// Content-Disposition.
+func sanitizeFilename(raw string) string {
+	s := strings.Map(safeFilenameChar, raw)
+	if len(s) > 255 {
+		s = s[:255]
+	}
+	return s
+}
+
+// enforceIngestRateLimit keeps a Redis-backed sliding counter per
+// system and rejects requests past the per-minute and per-hour caps.
+// Returns (allowed, retryAfterSeconds). A Redis outage fails open —
+// a DoS mitigation should never itself become the cause of a service
+// outage, and the inline retention + auth gates still apply.
+//
+// Setting BackupRateLimitPerMinute or BackupRateLimitPerHour to 0 or
+// below disables the corresponding check; setting both to 0 turns the
+// limiter off entirely (useful in tests and for emergencies).
+func enforceIngestRateLimit(ctx context.Context, systemKey string) (bool, int) {
+	perMin := configuration.Config.BackupRateLimitPerMinute
+	perHour := configuration.Config.BackupRateLimitPerHour
+	if perMin <= 0 && perHour <= 0 {
+		return true, 0
+	}
+	rdb := queue.GetClient()
+	if rdb == nil {
+		// Redis not initialised — only happens in tests or during a
+		// brief boot window. Fail open.
+		return true, 0
+	}
+	now := time.Now().Unix()
+
+	minuteBucket := now / 60
+	hourBucket := now / 3600
+	minuteKey := fmt.Sprintf("backup:rate:min:%s:%d", systemKey, minuteBucket)
+	hourKey := fmt.Sprintf("backup:rate:hour:%s:%d", systemKey, hourBucket)
+
+	minCount, err := rdb.Incr(ctx, minuteKey).Result()
+	if err != nil {
+		logger.Warn().Err(err).Str("system_key", systemKey).Msg("rate-limit counter unreachable, failing open")
+		return true, 0
+	}
+	if minCount == 1 {
+		_ = rdb.Expire(ctx, minuteKey, 90*time.Second).Err()
+	}
+	if perMin > 0 && minCount > int64(perMin) {
+		retry := 60 - int(now%60)
+		return false, retry
+	}
+
+	hourCount, err := rdb.Incr(ctx, hourKey).Result()
+	if err != nil {
+		logger.Warn().Err(err).Str("system_key", systemKey).Msg("rate-limit counter unreachable, failing open")
+		return true, 0
+	}
+	if hourCount == 1 {
+		_ = rdb.Expire(ctx, hourKey, 2*time.Hour).Err()
+	}
+	if perHour > 0 && hourCount > int64(perHour) {
+		retry := 3600 - int(now%3600)
+		return false, retry
+	}
+	return true, 0
+}
+
+// remoteAddrIP returns the true peer IP observed by the server. Unlike
+// c.ClientIP() it never honours X-Forwarded-For, so a malicious
+// appliance cannot forge the uploader IP in audit metadata.
+func remoteAddrIP(c *gin.Context) string {
+	host, _, err := net.SplitHostPort(c.Request.RemoteAddr)
+	if err != nil {
+		return c.Request.RemoteAddr
+	}
+	return host
+}
+
+// countingReader tracks how many bytes the downstream consumer has read
+// so the handler can reject clients that under-declare Content-Length
+// (which would otherwise leave the SHA-256 tee hashing only a prefix of
+// the uploaded body).
+type countingReader struct {
+	r io.Reader
+	n atomic.Int64
+}
+
+func (c *countingReader) Read(p []byte) (int, error) {
+	n, err := c.r.Read(p)
+	if n > 0 {
+		c.n.Add(int64(n))
+	}
+	return n, err
+}
+
+// BackupMetadata is the JSON payload returned for list/upload operations.
+// The uploader IP stays in S3 object metadata (audit) but is not surfaced
+// back to the appliance nor to the admin API.
+type BackupMetadata struct {
+	ID         string    `json:"id"`
+	Filename   string    `json:"filename"`
+	Size       int64     `json:"size"`
+	SHA256     string    `json:"sha256"`
+	MimeType   string    `json:"mimetype"`
+	UploadedAt time.Time `json:"uploaded_at"`
+}
+
+// UploadBackup streams a configuration backup uploaded by an authenticated
+// appliance into the Garage-backed object store. The body is read as a single
+// stream, hashed with SHA-256 on the fly, and multipart-uploaded to S3. After
+// the object is committed the final SHA-256 is attached via CopyObject so it
+// appears in subsequent HEAD/GET metadata. Retention caps (count + total size)
+// are then enforced by pruning the oldest objects under the system's prefix.
+func UploadBackup(c *gin.Context) {
+	systemID, ok := getAuthenticatedSystemID(c)
+	if !ok {
+		c.JSON(http.StatusUnauthorized, response.Unauthorized("authentication required", nil))
+		return
+	}
+	systemKey, _ := getAuthenticatedSystemKey(c)
+
+	// Run the cheap header validations BEFORE consuming a rate-limit slot
+	// so a misbehaving appliance shipping zero-length or oversized requests
+	// does not lock itself out of legitimate retries. Rate limiting still
+	// protects against floods of well-formed requests.
+	if c.Request.ContentLength <= 0 {
+		c.JSON(http.StatusLengthRequired, response.Error(http.StatusLengthRequired, "Content-Length required", nil))
+		return
+	}
+	if c.Request.ContentLength > configuration.Config.BackupMaxUploadSize {
+		c.JSON(http.StatusRequestEntityTooLarge, response.Error(http.StatusRequestEntityTooLarge, "backup exceeds max upload size", gin.H{
+			"max_bytes": configuration.Config.BackupMaxUploadSize,
+		}))
+		return
+	}
+
+	// Per-system ingest rate limit — blocks flood-style abuse ahead of
+	// the expensive storage path.
+	if ok, retry := enforceIngestRateLimit(c.Request.Context(), systemKey); !ok {
+		c.Header("Retry-After", fmt.Sprintf("%d", retry))
+		c.JSON(http.StatusTooManyRequests, response.Error(http.StatusTooManyRequests, "upload rate limit exceeded", gin.H{
+			"retry_after_seconds": retry,
+		}))
+		return
+	}
+
+	orgID, err := lookupSystemOrgID(systemID)
+	if err != nil {
+		logger.Error().Err(err).Str("system_id", systemID).Msg("failed to resolve system organization")
+		c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to resolve system organization", nil))
+		return
+	}
+
+	ctx := c.Request.Context()
+	client, _, err := storage.BackupClient(ctx)
+	if err != nil {
+		logger.Error().Err(err).Msg("backup storage client unavailable")
+		c.JSON(http.StatusServiceUnavailable, response.Error(http.StatusServiceUnavailable, "backup storage unavailable", nil))
+		return
+	}
+
+	// Per-org aggregate quota — reject before streaming the body if the
+	// incoming upload would push the org over its ceiling. The check uses a
+	// Redis counter that's maintained incrementally on uploads and deletes
+	// (with TTL self-heal); the hot path no longer pays the cost of listing
+	// every backup object in the org on every upload. A failure to read AND
+	// recompute the counter fails CLOSED with 503 — the per-org quota is the
+	// principal blast-radius cap if an appliance credential is compromised,
+	// silently allowing the upload would let an attacker who can induce S3
+	// list errors lift the cap entirely.
+	if configuration.Config.BackupMaxSizePerOrg > 0 {
+		used, err := getOrgBackupUsage(ctx, client, orgID)
+		if err != nil {
+			logger.Error().Err(err).Str("org_id", orgID).Msg("org quota lookup failed; refusing upload (fail-closed)")
+			c.JSON(http.StatusServiceUnavailable, response.Error(http.StatusServiceUnavailable, "backup quota service unavailable, retry later", nil))
+			return
+		}
+		if used+c.Request.ContentLength > configuration.Config.BackupMaxSizePerOrg {
+			// Do not echo the measured usage back to the appliance — it
+			// would let a compromised client profile the entire org's
+			// consumption. The operator can inspect the server log.
+			logger.Warn().
+				Str("system_id", systemID).
+				Str("org_id", orgID).
+				Int64("used_bytes", used).
+				Int64("incoming_bytes", c.Request.ContentLength).
+				Int64("max_bytes", configuration.Config.BackupMaxSizePerOrg).
+				Msg("organization backup quota exceeded")
+			c.JSON(http.StatusRequestEntityTooLarge, response.Error(http.StatusRequestEntityTooLarge, "organization backup quota exceeded", nil))
+			return
+		}
+	}
+
+	backupID := uuid.Must(uuid.NewV7())
+	filename := extractFilename(c.GetHeader("X-Filename"), backupID.String())
+	ext := extractExtension(filename)
+	// Object key uses system_key (the stable NETH-…-shaped identifier)
+	// instead of the internal UUID system_id so operators browsing a raw
+	// bucket listing can recognise each system at a glance.
+	key := fmt.Sprintf("%s/%s/%s%s", orgID, systemKey, backupID.String(), ext)
+	contentType := c.GetHeader("Content-Type")
+	if contentType == "" {
+		contentType = "application/octet-stream"
+	}
+
+	hasher := sha256.New()
+	limitedBody := http.MaxBytesReader(c.Writer, c.Request.Body, configuration.Config.BackupMaxUploadSize)
+	counter := &countingReader{r: limitedBody}
+	teeReader := io.TeeReader(counter, hasher)
+
+	metadata := map[string]string{
+		"filename":    sanitizeFilename(filename),
+		"uploader-ip": remoteAddrIP(c),
+		"sha256":      "pending",
+	}
+
+	_, err = client.PutObject(ctx, &s3.PutObjectInput{
+		Bucket:               aws.String(configuration.Config.BackupS3Bucket),
+		Key:                  aws.String(key),
+		Body:                 teeReader,
+		ContentLength:        aws.Int64(c.Request.ContentLength),
+		ContentType:          aws.String(contentType),
+		Metadata:             metadata,
+		ServerSideEncryption: s3types.ServerSideEncryptionAes256,
+	})
+	if err != nil {
+		var maxBytesErr *http.MaxBytesError
+		if errors.As(err, &maxBytesErr) {
+			c.JSON(http.StatusRequestEntityTooLarge, response.Error(http.StatusRequestEntityTooLarge, "backup exceeds max upload size", nil))
+			return
+		}
+		logger.Error().Err(err).Str("system_id", systemID).Str("key", key).Msg("backup upload failed")
+		c.JSON(http.StatusBadGateway, response.Error(http.StatusBadGateway, "backup upload failed", nil))
+		return
+	}
+
+	// Reconcile actual body length against the declared Content-Length.
+	// A mismatch means either the client lied in the header (truncating
+	// the SHA-256 tee) or the S3 client silently padded the request.
+	// In either case the stored object cannot be trusted — drop it.
+	observed := counter.n.Load()
+	if observed != c.Request.ContentLength {
+		deleteOrphanObject(ctx, client, key)
+		logger.Error().
+			Str("system_id", systemID).
+			Str("key", key).
+			Int64("declared_bytes", c.Request.ContentLength).
+			Int64("observed_bytes", observed).
+			Msg("upload length mismatch — object dropped")
+		c.JSON(http.StatusBadRequest, response.BadRequest("upload length mismatch", nil))
+		return
+	}
+
+	sha := hex.EncodeToString(hasher.Sum(nil))
+	metadata["sha256"] = sha
+
+	// Rewrite the object's metadata with the real checksum. CopyObject
+	// against the same key is a server-side operation, so it is fast and
+	// does not transfer the body again. Any failure here leaves the
+	// object with sha256=pending, which would silently lie to later
+	// readers — drop the object and surface a 502 instead.
+	_, err = client.CopyObject(ctx, &s3.CopyObjectInput{
+		Bucket:               aws.String(configuration.Config.BackupS3Bucket),
+		Key:                  aws.String(key),
+		CopySource:           aws.String(configuration.Config.BackupS3Bucket + "/" + key),
+		Metadata:             metadata,
+		MetadataDirective:    s3types.MetadataDirectiveReplace,
+		ContentType:          aws.String(contentType),
+		ServerSideEncryption: s3types.ServerSideEncryptionAes256,
+	})
+	if err != nil {
+		deleteOrphanObject(ctx, client, key)
+		logger.Error().Err(err).Str("key", key).Msg("failed to attach final sha256 metadata — object dropped")
+		c.JSON(http.StatusBadGateway, response.Error(http.StatusBadGateway, "failed to persist backup metadata", nil))
+		return
+	}
+
+	head, err := client.HeadObject(ctx, &s3.HeadObjectInput{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Key:    aws.String(key),
+	})
+	if err != nil {
+		logger.Warn().Err(err).Str("key", key).Msg("failed to HEAD uploaded backup")
+	}
+
+	var size int64
+	if head != nil {
+		size = aws.ToInt64(head.ContentLength)
+	}
+
+	// Bump the org-usage counter BEFORE retention runs, so any subsequent
+	// prune correctly subtracts what it removes. Retention may push the
+	// total back down within the same request.
+	adjustOrgBackupUsage(ctx, orgID, size)
+
+	if err := enforceBackupRetention(ctx, client, orgID, systemKey); err != nil {
+		logger.Warn().Err(err).Str("system_id", systemID).Str("system_key", systemKey).Msg("retention enforcement failed")
+	}
+
+	logger.Info().
+		Str("component", "backup").
+		Str("operation", "upload").
+		Str("system_id", systemID).
+		Str("org_id", orgID).
+		Str("key", key).
+		Str("sha256", sha).
+		Int64("size", size).
+		Msg("backup stored")
+
+	c.JSON(http.StatusCreated, response.Created("backup stored", BackupMetadata{
+		ID:         backupID.String() + ext,
+		Filename:   metadata["filename"],
+		Size:       size,
+		SHA256:     sha,
+		MimeType:   contentType,
+		UploadedAt: time.Now().UTC(),
+	}))
+}
+
+// ListBackups returns the list of backups stored for the authenticated system.
+func ListBackups(c *gin.Context) {
+	systemID, ok := getAuthenticatedSystemID(c)
+	if !ok {
+		c.JSON(http.StatusUnauthorized, response.Unauthorized("authentication required", nil))
+		return
+	}
+	systemKey, _ := getAuthenticatedSystemKey(c)
+
+	orgID, err := lookupSystemOrgID(systemID)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to resolve system organization", nil))
+		return
+	}
+
+	ctx := c.Request.Context()
+	client, _, err := storage.BackupClient(ctx)
+	if err != nil {
+		c.JSON(http.StatusServiceUnavailable, response.Error(http.StatusServiceUnavailable, "backup storage unavailable", nil))
+		return
+	}
+
+	items, err := listBackupsForSystem(ctx, client, orgID, systemKey)
+	if err != nil {
+		logger.Error().Err(err).Str("system_id", systemID).Str("system_key", systemKey).Msg("list backups failed")
+		c.JSON(http.StatusBadGateway, response.Error(http.StatusBadGateway, "failed to list backups", nil))
+		return
+	}
+
+	c.JSON(http.StatusOK, response.OK("backups listed", gin.H{"backups": items}))
+}
+
+// DownloadBackup streams a stored backup back to the authenticated appliance
+// for restore operations.
+func DownloadBackup(c *gin.Context) {
+	systemID, ok := getAuthenticatedSystemID(c)
+	if !ok {
+		c.JSON(http.StatusUnauthorized, response.Unauthorized("authentication required", nil))
+		return
+	}
+	systemKey, _ := getAuthenticatedSystemKey(c)
+
+	backupID := c.Param("id")
+	if backupID == "" {
+		c.JSON(http.StatusBadRequest, response.BadRequest("backup id required", nil))
+		return
+	}
+	if !isValidBackupID(backupID) {
+		c.JSON(http.StatusBadRequest, response.BadRequest("invalid backup id", nil))
+		return
+	}
+
+	orgID, err := lookupSystemOrgID(systemID)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to resolve system organization", nil))
+		return
+	}
+
+	ctx := c.Request.Context()
+	client, _, err := storage.BackupClient(ctx)
+	if err != nil {
+		c.JSON(http.StatusServiceUnavailable, response.Error(http.StatusServiceUnavailable, "backup storage unavailable", nil))
+		return
+	}
+
+	key := fmt.Sprintf("%s/%s/%s", orgID, systemKey, backupID)
+	obj, err := client.GetObject(ctx, &s3.GetObjectInput{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Key:    aws.String(key),
+	})
+	if err != nil {
+		var nsk *s3types.NoSuchKey
+		if errors.As(err, &nsk) {
+			c.JSON(http.StatusNotFound, response.NotFound("backup not found", nil))
+			return
+		}
+		logger.Error().Err(err).Str("key", key).Msg("get backup failed")
+		c.JSON(http.StatusBadGateway, response.Error(http.StatusBadGateway, "failed to fetch backup", nil))
+		return
+	}
+	defer func() {
+		if cerr := obj.Body.Close(); cerr != nil {
+			logger.Warn().Err(cerr).Str("key", key).Msg("backup body close failed")
+		}
+	}()
+
+	// Force a fixed binary content type regardless of what the appliance
+	// declared at upload. Echoing a client-supplied Content-Type back on
+	// a GET turns a compromised appliance into a reflected-XSS vector on
+	// this domain for anyone who opens the URL in a browser.
+	c.Header("Content-Type", "application/octet-stream")
+	c.Header("X-Content-Type-Options", "nosniff")
+	if obj.ContentLength != nil {
+		c.Header("Content-Length", fmt.Sprintf("%d", aws.ToInt64(obj.ContentLength)))
+	}
+	if filename, ok := obj.Metadata["filename"]; ok && filename != "" {
+		// Filename has already been sanitized at ingest, but apply the
+		// same filter again on the read path in case an object was
+		// written by an older build or directly via the S3 API.
+		c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, sanitizeFilename(filename)))
+	}
+	c.Status(http.StatusOK)
+
+	if _, err := io.Copy(c.Writer, obj.Body); err != nil {
+		logger.Warn().Err(err).Str("key", key).Msg("backup stream interrupted")
+	}
+}
+
+// DeleteBackup removes a single backup belonging to the authenticated
+// appliance. The object key is server-derived from the Basic-Auth
+// identity and the :id path parameter, so the caller can only target
+// backups under its own {org_id}/{system_key}/ prefix.
+func DeleteBackup(c *gin.Context) {
+	systemID, ok := getAuthenticatedSystemID(c)
+	if !ok {
+		c.JSON(http.StatusUnauthorized, response.Unauthorized("authentication required", nil))
+		return
+	}
+	systemKey, _ := getAuthenticatedSystemKey(c)
+
+	backupID := c.Param("id")
+	if backupID == "" {
+		c.JSON(http.StatusBadRequest, response.BadRequest("backup id required", nil))
+		return
+	}
+	if !isValidBackupID(backupID) {
+		c.JSON(http.StatusBadRequest, response.BadRequest("invalid backup id", nil))
+		return
+	}
+
+	orgID, err := lookupSystemOrgID(systemID)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, response.InternalServerError("failed to resolve system organization", nil))
+		return
+	}
+
+	ctx := c.Request.Context()
+	client, _, err := storage.BackupClient(ctx)
+	if err != nil {
+		c.JSON(http.StatusServiceUnavailable, response.Error(http.StatusServiceUnavailable, "backup storage unavailable", nil))
+		return
+	}
+
+	key := fmt.Sprintf("%s/%s/%s", orgID, systemKey, backupID)
+
+	// S3 DeleteObject is idempotent and does not return NoSuchKey —
+	// probe with HeadObject first so a phantom id surfaces as 404
+	// instead of a misleading 200.
+	head, err := client.HeadObject(ctx, &s3.HeadObjectInput{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Key:    aws.String(key),
+	})
+	if err != nil {
+		var nf *s3types.NotFound
+		if errors.As(err, &nf) {
+			c.JSON(http.StatusNotFound, response.NotFound("backup not found", nil))
+			return
+		}
+		logger.Error().Err(err).Str("key", key).Msg("head backup failed")
+		c.JSON(http.StatusBadGateway, response.Error(http.StatusBadGateway, "failed to delete backup", nil))
+		return
+	}
+	deletedSize := aws.ToInt64(head.ContentLength)
+
+	_, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Key:    aws.String(key),
+	})
+	if err != nil {
+		logger.Error().Err(err).Str("key", key).Msg("delete backup failed")
+		c.JSON(http.StatusBadGateway, response.Error(http.StatusBadGateway, "failed to delete backup", nil))
+		return
+	}
+
+	// Keep the org-usage counter consistent so the next upload's quota
+	// check sees the freed space.
+	adjustOrgBackupUsage(ctx, orgID, -deletedSize)
+
+	logger.Info().
+		Str("component", "backup").
+		Str("operation", "delete").
+		Str("system_id", systemID).
+		Str("org_id", orgID).
+		Str("key", key).
+		Msg("backup deleted by appliance")
+
+	c.JSON(http.StatusOK, response.OK("backup deleted", gin.H{"id": backupID}))
+}
+
+// listBackupsForSystem lists every backup object under the
+// {org}/{system_key} prefix and maps it to BackupMetadata. Paginates
+// explicitly so the per-system list is never silently truncated at the
+// S3 1000-item response cap.
+func listBackupsForSystem(ctx context.Context, client *s3.Client, orgID, systemKey string) ([]BackupMetadata, error) {
+	prefix := fmt.Sprintf("%s/%s/", orgID, systemKey)
+
+	items := make([]BackupMetadata, 0)
+	paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Prefix: aws.String(prefix),
+	})
+	for paginator.HasMorePages() {
+		page, err := paginator.NextPage(ctx)
+		if err != nil {
+			return nil, err
+		}
+		for _, o := range page.Contents {
+			key := aws.ToString(o.Key)
+			id := strings.TrimPrefix(key, prefix)
+
+			head, err := client.HeadObject(ctx, &s3.HeadObjectInput{
+				Bucket: aws.String(configuration.Config.BackupS3Bucket),
+				Key:    aws.String(key),
+			})
+			if err != nil {
+				logger.Warn().Err(err).Str("key", key).Msg("head failed during list")
+				continue
+			}
+
+			md := head.Metadata
+			items = append(items, BackupMetadata{
+				ID:         id,
+				Filename:   md["filename"],
+				Size:       aws.ToInt64(head.ContentLength),
+				SHA256:     md["sha256"],
+				MimeType:   aws.ToString(head.ContentType),
+				UploadedAt: aws.ToTime(o.LastModified),
+			})
+		}
+	}
+
+	sort.Slice(items, func(i, j int) bool {
+		return items[i].UploadedAt.After(items[j].UploadedAt)
+	})
+
+	return items, nil
+}
+
+// enforceBackupRetention deletes the oldest backups under a system's
+// prefix until both BackupMaxPerSystem and BackupMaxSizePerSystem
+// limits are met. It is serialised by a Redis lock keyed on the system
+// (so two concurrent uploads cannot both delete the same victim and
+// race over the survivor count) and orders objects by their key —
+// because the keys embed a UUIDv7, lexicographic order is monotonic in
+// upload time and immune to S3 LastModified clock skew.
+func enforceBackupRetention(ctx context.Context, client *s3.Client, orgID, systemKey string) error {
+	lockKey := fmt.Sprintf("backup:retention:%s", systemKey)
+	rdb := queue.GetClient()
+	locked, err := rdb.SetNX(ctx, lockKey, "1", 60*time.Second).Result()
+	if err != nil {
+		// Redis unreachable — skip retention this round; the next
+		// upload will retry. Better to leave one extra backup than
+		// to delete the wrong one.
+		logger.Warn().Err(err).Str("system_key", systemKey).Msg("retention: lock acquire failed, skipping")
+		return nil
+	}
+	if !locked {
+		// Another upload is already pruning this system; do nothing.
+		return nil
+	}
+	defer func() {
+		if _, err := rdb.Del(ctx, lockKey).Result(); err != nil {
+			logger.Warn().Err(err).Str("system_key", systemKey).Msg("retention: lock release failed")
+		}
+	}()
+
+	prefix := fmt.Sprintf("%s/%s/", orgID, systemKey)
+
+	objs := make([]s3types.Object, 0)
+	paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Prefix: aws.String(prefix),
+	})
+	for paginator.HasMorePages() {
+		page, err := paginator.NextPage(ctx)
+		if err != nil {
+			return err
+		}
+		objs = append(objs, page.Contents...)
+	}
+
+	// Sort by key (ascending). The {uuid}.{ext} suffix begins with a
+	// UUIDv7 whose first 48 bits are the upload's millisecond
+	// timestamp, so lexicographic order matches chronological order
+	// without depending on the storage backend's clock.
+	sort.Slice(objs, func(i, j int) bool {
+		return aws.ToString(objs[i].Key) < aws.ToString(objs[j].Key)
+	})
+
+	var totalSize int64
+	for _, o := range objs {
+		totalSize += aws.ToInt64(o.Size)
+	}
+
+	maxCount := configuration.Config.BackupMaxPerSystem
+	maxSize := configuration.Config.BackupMaxSizePerSystem
+
+	for len(objs) > 0 && (len(objs) > maxCount || totalSize > maxSize) {
+		victim := objs[0]
+		objs = objs[1:]
+		victimSize := aws.ToInt64(victim.Size)
+		_, delErr := client.DeleteObject(ctx, &s3.DeleteObjectInput{
+			Bucket: aws.String(configuration.Config.BackupS3Bucket),
+			Key:    victim.Key,
+		})
+		if delErr != nil {
+			logger.Warn().Err(delErr).Str("key", aws.ToString(victim.Key)).Msg("retention: delete failed")
+			continue
+		}
+		totalSize -= victimSize
+		// Keep the org-usage counter consistent so the next upload's quota
+		// check sees the freed space.
+		adjustOrgBackupUsage(ctx, orgID, -victimSize)
+		logger.Info().Str("key", aws.ToString(victim.Key)).Msg("retention: oldest backup pruned")
+	}
+
+	return nil
+}
+
+// computeOrgBackupUsage returns the sum of every backup object size
+// stored under the given organization prefix. Paginates to handle orgs
+// with many systems × many retained backups.
+func computeOrgBackupUsage(ctx context.Context, client *s3.Client, orgID string) (int64, error) {
+	prefix := fmt.Sprintf("%s/", orgID)
+	var total int64
+	paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Prefix: aws.String(prefix),
+	})
+	for paginator.HasMorePages() {
+		page, err := paginator.NextPage(ctx)
+		if err != nil {
+			return 0, err
+		}
+		for _, o := range page.Contents {
+			total += aws.ToInt64(o.Size)
+		}
+	}
+	return total, nil
+}
+
+// orgUsageCacheKey returns the Redis key holding the current per-org backup
+// usage in bytes. The counter is maintained incrementally on every
+// successful upload and on every retention/delete; a 25h TTL bounds the
+// drift caused by out-of-band changes (e.g. backend-side purge on system
+// destroy) and lets the next missed read self-heal via S3 list.
+func orgUsageCacheKey(orgID string) string {
+	return fmt.Sprintf("backup:org_usage:%s", orgID)
+}
+
+const orgUsageCacheTTL = 25 * time.Hour
+
+// getOrgBackupUsage returns the org-level usage in bytes. It tries the Redis
+// counter first; on miss it recomputes by listing S3 and caches the result.
+// A failure to recompute (S3 list error) is propagated so the caller can
+// fail closed: the per-org quota is the principal blast-radius cap when an
+// appliance is compromised, so silently allowing the upload would let an
+// attacker who induces S3 errors lift the cap entirely.
+func getOrgBackupUsage(ctx context.Context, client *s3.Client, orgID string) (int64, error) {
+	rdb := queue.GetClient()
+	if rdb != nil {
+		key := orgUsageCacheKey(orgID)
+		if raw, err := rdb.Get(ctx, key).Result(); err == nil {
+			if n, parseErr := strconv.ParseInt(raw, 10, 64); parseErr == nil {
+				return n, nil
+			}
+			// Stale or malformed value — drop it and fall through to recompute.
+			_ = rdb.Del(ctx, key).Err()
+		}
+	}
+
+	used, err := computeOrgBackupUsage(ctx, client, orgID)
+	if err != nil {
+		return 0, err
+	}
+	if rdb != nil {
+		// Best-effort cache write; a Redis hiccup costs the next request a
+		// second list but does not break correctness.
+		_ = rdb.Set(ctx, orgUsageCacheKey(orgID), used, orgUsageCacheTTL).Err()
+	}
+	return used, nil
+}
+
+// adjustOrgBackupUsage shifts the counter by delta (positive on upload,
+// negative on retention or admin delete). Best-effort: a Redis miss skips
+// the update, the next read recomputes from S3.
+func adjustOrgBackupUsage(ctx context.Context, orgID string, delta int64) {
+	if delta == 0 {
+		return
+	}
+	rdb := queue.GetClient()
+	if rdb == nil {
+		return
+	}
+	key := orgUsageCacheKey(orgID)
+	if err := rdb.IncrBy(ctx, key, delta).Err(); err != nil {
+		// Counter is missing or Redis errored — invalidate so the next
+		// read goes back to the S3 list.
+		_ = rdb.Del(ctx, key).Err()
+		return
+	}
+	// Refresh TTL on every increment so an org that's actively using
+	// backups never lets its counter expire underneath it.
+	_ = rdb.Expire(ctx, key, orgUsageCacheTTL).Err()
+}
+
+// lookupSystemOrgID returns the organization_id for the given system_id.
+func lookupSystemOrgID(systemID string) (string, error) {
+	var orgID string
+	err := database.DB.QueryRow(
+		`SELECT organization_id FROM systems WHERE id = $1 AND deleted_at IS NULL`,
+		systemID,
+	).Scan(&orgID)
+	if err != nil {
+		return "", err
+	}
+	return orgID, nil
+}
+
+// extractFilename returns a safe user-facing filename, falling back to the
+// backup UUID if the appliance did not provide one.
+func extractFilename(headerValue, fallbackID string) string {
+	h := strings.TrimSpace(headerValue)
+	if h == "" {
+		return "backup-" + fallbackID
+	}
+	// Strip any path components so headers cannot escape the bucket prefix.
+	if i := strings.LastIndexAny(h, "/\\"); i >= 0 {
+		h = h[i+1:]
+	}
+	return h
+}
+
+// deleteOrphanObject removes an S3 object that was written but whose
+// metadata could not be committed; it is always best-effort (the caller
+// already owns the surfaced error) and only logs the outcome.
+func deleteOrphanObject(ctx context.Context, client *s3.Client, key string) {
+	_, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{
+		Bucket: aws.String(configuration.Config.BackupS3Bucket),
+		Key:    aws.String(key),
+	})
+	if err != nil {
+		logger.Warn().Err(err).Str("key", key).Msg("orphan backup cleanup failed")
+	}
+}
+
+// extractExtension returns the file extension from a filename, including the
+// leading dot. Only the small set of extensions the backend's download/delete
+// handlers recognise (backupIDPattern) is accepted — anything else collapses
+// to ".bin" so a backup object can never end up with a suffix the admin UI
+// cannot match and therefore cannot download or delete.
+func extractExtension(filename string) string {
+	lower := strings.ToLower(filename)
+	for _, compound := range []string{".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst"} {
+		if strings.HasSuffix(lower, compound) {
+			return compound
+		}
+	}
+	if i := strings.LastIndex(lower, "."); i >= 0 && i < len(lower)-1 {
+		ext := lower[i:]
+		switch ext {
+		case ".gpg", ".bin":
+			return ext
+		}
+	}
+	return ".bin"
+}
diff --git a/collect/methods/backups_integration_test.go b/collect/methods/backups_integration_test.go
new file mode 100644
index 000000000..5fac5c3d1
--- /dev/null
+++ b/collect/methods/backups_integration_test.go
@@ -0,0 +1,171 @@
+//go:build integration
+// +build integration
+
+/*
+ * Copyright (C) 2026 Nethesis S.r.l.
+ * http://www.nethesis.it - info@nethesis.it
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * author: Edoardo Spadoni <edoardo.spadoni@nethesis.it>
+ */
+
+package methods
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	awsconfig "github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/credentials"
+	"github.com/aws/aws-sdk-go-v2/service/s3"
+	s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestBackupStorageRoundTrip runs the same sequence of S3 operations the
+// UploadBackup, ListBackups, DownloadBackup and DeleteSystemBackup
+// handlers perform — PutObject, CopyObject (for the sha256 metadata
+// reconciliation), HeadObject, ListObjectsV2, GetObject, DeleteObject
+// — against a live S3-compatible endpoint. It does not exercise auth,
+// DB lookups, or the Gin layer; its purpose is to catch any drift
+// between what the SDK emits and what the target server (MinIO,
+// Garage, Spaces, AWS S3) accepts.
+//
+// Runs only under `go test -tags=integration` and only when the
+// `BACKUP_S3_*` environment variables are set — point it at any
+// bucket you own (DO Spaces, AWS S3, R2, MinIO, …). It is not part
+// of the default CI pipeline; run it manually when touching the S3
+// integration layer.
+func TestBackupStorageRoundTrip(t *testing.T) {
+	endpoint := os.Getenv("BACKUP_S3_ENDPOINT")
+	bucket := os.Getenv("BACKUP_S3_BUCKET")
+	accessKey := os.Getenv("BACKUP_S3_ACCESS_KEY")
+	secretKey := os.Getenv("BACKUP_S3_SECRET_KEY")
+	if endpoint == "" || bucket == "" || accessKey == "" || secretKey == "" {
+		t.Skip("BACKUP_S3_* env vars not set; skipping integration test")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	client := buildIntegrationClient(t, endpoint, accessKey, secretKey)
+
+	testKey := fmt.Sprintf("integration-test/%d.bin", time.Now().UnixNano())
+	body := []byte("hello backup integration test\n")
+
+	// Clean up on any exit path — best effort.
+	defer func() {
+		_, _ = client.DeleteObject(context.Background(), &s3.DeleteObjectInput{
+			Bucket: aws.String(bucket),
+			Key:    aws.String(testKey),
+		})
+	}()
+
+	t.Run("put_object_with_metadata_placeholder", func(t *testing.T) {
+		_, err := client.PutObject(ctx, &s3.PutObjectInput{
+			Bucket:               aws.String(bucket),
+			Key:                  aws.String(testKey),
+			Body:                 bytes.NewReader(body),
+			ContentLength:        aws.Int64(int64(len(body))),
+			ContentType:          aws.String("application/octet-stream"),
+			Metadata:             map[string]string{"sha256": "pending"},
+			ServerSideEncryption: s3types.ServerSideEncryptionAes256,
+		})
+		require.NoError(t, err)
+	})
+
+	t.Run("copy_object_replaces_metadata", func(t *testing.T) {
+		_, err := client.CopyObject(ctx, &s3.CopyObjectInput{
+			Bucket:               aws.String(bucket),
+			Key:                  aws.String(testKey),
+			CopySource:           aws.String(bucket + "/" + testKey),
+			Metadata:             map[string]string{"sha256": "final-hash", "filename": "test.bin"},
+			MetadataDirective:    s3types.MetadataDirectiveReplace,
+			ContentType:          aws.String("application/octet-stream"),
+			ServerSideEncryption: s3types.ServerSideEncryptionAes256,
+		})
+		require.NoError(t, err)
+	})
+
+	t.Run("head_object_returns_final_metadata", func(t *testing.T) {
+		head, err := client.HeadObject(ctx, &s3.HeadObjectInput{
+			Bucket: aws.String(bucket),
+			Key:    aws.String(testKey),
+		})
+		require.NoError(t, err)
+		assert.Equal(t, "final-hash", head.Metadata["sha256"])
+		assert.Equal(t, "test.bin", head.Metadata["filename"])
+		assert.Equal(t, int64(len(body)), aws.ToInt64(head.ContentLength))
+	})
+
+	t.Run("list_objects_includes_test_key", func(t *testing.T) {
+		paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
+			Bucket: aws.String(bucket),
+			Prefix: aws.String("integration-test/"),
+		})
+		found := false
+		for paginator.HasMorePages() {
+			page, err := paginator.NextPage(ctx)
+			require.NoError(t, err)
+			for _, obj := range page.Contents {
+				if aws.ToString(obj.Key) == testKey {
+					found = true
+					assert.Equal(t, int64(len(body)), aws.ToInt64(obj.Size))
+				}
+			}
+		}
+		assert.True(t, found, "test key must be present in the listing")
+	})
+
+	t.Run("get_object_returns_exact_body", func(t *testing.T) {
+		get, err := client.GetObject(ctx, &s3.GetObjectInput{
+			Bucket: aws.String(bucket),
+			Key:    aws.String(testKey),
+		})
+		require.NoError(t, err)
+		defer func() {
+			_ = get.Body.Close()
+		}()
+		gotBody, err := io.ReadAll(get.Body)
+		require.NoError(t, err)
+		assert.Equal(t, body, gotBody)
+	})
+
+	t.Run("delete_object_removes_the_key", func(t *testing.T) {
+		_, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{
+			Bucket: aws.String(bucket),
+			Key:    aws.String(testKey),
+		})
+		require.NoError(t, err)
+
+		_, err = client.HeadObject(ctx, &s3.HeadObjectInput{
+			Bucket: aws.String(bucket),
+			Key:    aws.String(testKey),
+		})
+		assert.Error(t, err, "the key must be gone after delete")
+	})
+}
+
+func buildIntegrationClient(t *testing.T, endpoint, accessKey, secretKey string) *s3.Client {
+	t.Helper()
+	cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
+		awsconfig.WithRegion("us-east-1"),
+		awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
+	)
+	require.NoError(t, err)
+	return s3.NewFromConfig(cfg, func(o *s3.Options) {
+		o.BaseEndpoint = aws.String(endpoint)
+		// Local emulators and many S3-compatible gateways reach objects
+		// via path-style URLs. Safe to force on for the integration
+		// test regardless of provider.
+		o.UsePathStyle = true
+	})
+}
diff --git a/collect/methods/backups_unit_test.go b/collect/methods/backups_unit_test.go
new file mode 100644
index 000000000..c42a393e4
--- /dev/null
+++ b/collect/methods/backups_unit_test.go
@@ -0,0 +1,235 @@
+/*
+ * Copyright (C) 2026 Nethesis S.r.l.
+ * http://www.nethesis.it - info@nethesis.it
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * author: Edoardo Spadoni <edoardo.spadoni@nethesis.it>
+ */
+
+package methods
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	"github.com/nethesis/my/collect/configuration"
+)
+
+func TestExtractFilename(t *testing.T) {
+	tests := []struct {
+		name     string
+		header   string
+		fallback string
+		want     string
+	}{
+		{"empty falls back to backup-<id>", "", "abc-123", "backup-abc-123"},
+		{"whitespace falls back", "   ", "abc-123", "backup-abc-123"},
+		{"keeps plain filename", "daily.tar.gz", "x", "daily.tar.gz"},
+		{"strips directory prefix", "../../etc/passwd", "x", "passwd"},
+		{"strips windows-style prefix", `C:\evil\dump.gpg`, "x", "dump.gpg"},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.Equal(t, tc.want, extractFilename(tc.header, tc.fallback))
+		})
+	}
+}
+
+func TestIsValidBackupID(t *testing.T) {
+	tests := []struct {
+		name string
+		in   string
+		ok   bool
+	}{
+		// UUIDv7 canonical form — version nibble 7, variant nibble 8/9/a/b.
+		{"uuidv7 bare", "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8", true},
+		{"uuidv7 with tar.gz", "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.tar.gz", true},
+		{"uuidv7 with gpg", "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.gpg", true},
+		{"uuidv7 with bin", "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.bin", true},
+		{"uuidv7 uppercase normalised", "01934FAB-BC33-7890-A1B2-C3D4E5F6A7B8.TAR.GZ", true},
+		// Rejected shapes.
+		{"uuidv4 (wrong version nibble)", "01934fab-bc33-4890-a1b2-c3d4e5f6a7b8", false},
+		{"path traversal", "../../etc/passwd", false},
+		{"encoded slash traversal", "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8..%2Fetc%2Fpasswd", false},
+		{"slash injection", "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8/extra", false},
+		{"unknown extension", "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8.sh", false},
+		{"trailing whitespace", "01934fab-bc33-7890-a1b2-c3d4e5f6a7b8 ", false},
+		{"empty", "", false},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.Equal(t, tc.ok, isValidBackupID(tc.in))
+		})
+	}
+}
+
+func TestExtractExtension(t *testing.T) {
+	tests := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{"tar.gz compound preserved", "dump.tar.gz", ".tar.gz"},
+		{"tar.xz compound preserved", "dump.tar.xz", ".tar.xz"},
+		{"tar.bz2 compound preserved", "dump.tar.bz2", ".tar.bz2"},
+		{"tar.zst compound preserved", "dump.tar.zst", ".tar.zst"},
+		{"simple gpg extension", "backup.gpg", ".gpg"},
+		{"uppercase normalized", "backup.GPG", ".gpg"},
+		{"no extension defaults to .bin", "backup", ".bin"},
+		{"trailing dot defaults to .bin", "backup.", ".bin"},
+		{"unknown extension coerced to .bin", "backup.rar", ".bin"},
+		{"script extension coerced to .bin", "backup.sh", ".bin"},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.Equal(t, tc.want, extractExtension(tc.in))
+		})
+	}
+}
+
+func TestSanitizeFilename(t *testing.T) {
+	tests := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{"keeps safe charset", "daily-backup_42.tar.gz", "daily-backup_42.tar.gz"},
+		{"strips CRLF", "foo\r\nSet-Cookie: x=1.gpg", "foo__Set-Cookie__x_1.gpg"},
+		{"strips control bytes", "a\x00b\x01c.bin", "a_b_c.bin"},
+		{"strips html tags", "<img onerror=alert(1)>.gpg", "_img_onerror_alert_1__.gpg"},
+		{"strips spaces and punctuation", "my backup [2026].gpg", "my_backup__2026_.gpg"},
+		{"caps at 255 chars", strings.Repeat("a", 300), strings.Repeat("a", 255)},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			assert.Equal(t, tc.want, sanitizeFilename(tc.in))
+		})
+	}
+}
+
+func TestUploadBackupNoSystemID(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	router.POST("/backups", UploadBackup)
+
+	req := httptest.NewRequest("POST", "/backups", bytes.NewBufferString("payload"))
+	req.Header.Set("Content-Type", "application/octet-stream")
+
+	w := httptest.NewRecorder()
+	router.ServeHTTP(w, req)
+
+	assert.Equal(t, http.StatusUnauthorized, w.Code)
+
+	var body map[string]interface{}
+	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+	assert.Equal(t, "authentication required", body["message"])
+}
+
+func TestUploadBackupExceedsMaxSize(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	configuration.Config.BackupMaxUploadSize = 16
+
+	router := gin.New()
+	router.Use(func(c *gin.Context) {
+		c.Set("system_id", "sys-123")
+		c.Set("system_key", "my_sys_abc")
+		c.Next()
+	})
+	router.POST("/backups", UploadBackup)
+
+	payload := strings.Repeat("A", 128)
+	req := httptest.NewRequest("POST", "/backups", bytes.NewBufferString(payload))
+	req.Header.Set("Content-Type", "application/octet-stream")
+	req.ContentLength = int64(len(payload))
+
+	w := httptest.NewRecorder()
+	router.ServeHTTP(w, req)
+
+	assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
+
+	var body map[string]interface{}
+	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+	assert.Equal(t, "backup exceeds max upload size", body["message"])
+}
+
+func TestUploadBackupRequiresContentLength(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	configuration.Config.BackupMaxUploadSize = 1024
+
+	router := gin.New()
+	router.Use(func(c *gin.Context) {
+		c.Set("system_id", "sys-123")
+		c.Set("system_key", "my_sys_abc")
+		c.Next()
+	})
+	router.POST("/backups", UploadBackup)
+
+	req := httptest.NewRequest("POST", "/backups", strings.NewReader("body"))
+	req.Header.Set("Content-Type", "application/octet-stream")
+	req.ContentLength = -1 // simulate chunked / unknown length
+
+	w := httptest.NewRecorder()
+	router.ServeHTTP(w, req)
+
+	assert.Equal(t, http.StatusLengthRequired, w.Code)
+}
+
+// TestEnforceIngestRateLimitDisabled confirms the limiter is a no-op
+// when both caps are 0 — the configuration escape hatch used by tests
+// and by operators during an emergency.
+func TestEnforceIngestRateLimitDisabled(t *testing.T) {
+	prevMin := configuration.Config.BackupRateLimitPerMinute
+	prevHour := configuration.Config.BackupRateLimitPerHour
+	defer func() {
+		configuration.Config.BackupRateLimitPerMinute = prevMin
+		configuration.Config.BackupRateLimitPerHour = prevHour
+	}()
+	configuration.Config.BackupRateLimitPerMinute = 0
+	configuration.Config.BackupRateLimitPerHour = 0
+
+	for i := 0; i < 100; i++ {
+		allowed, retry := enforceIngestRateLimit(context.Background(), "sys-123")
+		require.True(t, allowed, "iteration %d", i)
+		require.Equal(t, 0, retry)
+	}
+}
+
+func TestListBackupsNoSystemID(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	router.GET("/backups", ListBackups)
+
+	req := httptest.NewRequest("GET", "/backups", nil)
+
+	w := httptest.NewRecorder()
+	router.ServeHTTP(w, req)
+
+	assert.Equal(t, http.StatusUnauthorized, w.Code)
+
+	var body map[string]interface{}
+	require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
+	assert.Equal(t, "authentication required", body["message"])
+}
+
+func TestDownloadBackupNoSystemID(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	router.GET("/backups/:id", DownloadBackup)
+
+	req := httptest.NewRequest("GET", "/backups/abc.tar.gz", nil)
+
+	w := httptest.NewRecorder()
+	router.ServeHTTP(w, req)
+
+	assert.Equal(t, http.StatusUnauthorized, w.Code)
+}
diff --git a/collect/methods/context_helpers.go b/collect/methods/context_helpers.go
index a3446211e..ca9255a13 100644
--- a/collect/methods/context_helpers.go
+++ b/collect/methods/context_helpers.go
@@ -26,3 +26,20 @@ func getAuthenticatedSystemID(c *gin.Context) (string, bool) {
 	}
 	return systemIDStr, true
 }
+
+// getAuthenticatedSystemKey extracts the system_key from the gin context
+// set by BasicAuthMiddleware. The key is the stable user-facing identifier
+// (NETH-XXXX-…) used both for BasicAuth and for the S3 object prefix, so
+// operators reading a raw bucket listing can tie each object back to a
+// recognisable system without an extra DB lookup.
+func getAuthenticatedSystemKey(c *gin.Context) (string, bool) {
+	systemKey, exists := c.Get("system_key")
+	if !exists {
+		return "", false
+	}
+	systemKeyStr, ok := systemKey.(string)
+	if !ok {
+		return "", false
+	}
+	return systemKeyStr, true
+}
diff --git a/collect/middleware/auth.go b/collect/middleware/auth.go
index e25c3fdaa..0e49c17f1 100644
--- a/collect/middleware/auth.go
+++ b/collect/middleware/auth.go
@@ -43,9 +43,10 @@ func jitteredTTL(base time.Duration) time.Duration {
 var inProcessAuthCache sync.Map
 
 type authCacheEntry struct {
-	systemID  string
-	valid     bool
-	expiresAt time.Time
+	systemID   string
+	valid      bool
+	insertedAt time.Time
+	expiresAt  time.Time
 }
 
 // authCacheKey returns a consistent cache key for in-process and Redis caches
@@ -56,6 +57,12 @@ func authCacheKey(systemKey, systemSecret string) string {
 
 // checkInProcessCache checks the in-process auth cache.
 // Returns (systemID, valid, found).
+//
+// An entry is dropped when:
+//   - it has reached its expiresAt, OR
+//   - it was inserted before the most recent invalidation for this
+//     system_key (closes the race where sync.Map.Range used by
+//     purgeSystemAuthCache might miss an entry installed during the iteration).
 func checkInProcessCache(systemKey, systemSecret string) (string, bool, bool) {
 	key := authCacheKey(systemKey, systemSecret)
 	val, ok := inProcessAuthCache.Load(key)
@@ -63,7 +70,12 @@ func checkInProcessCache(systemKey, systemSecret string) (string, bool, bool) {
 		return "", false, false
 	}
 	entry := val.(*authCacheEntry)
-	if time.Now().After(entry.expiresAt) {
+	now := time.Now()
+	if now.After(entry.expiresAt) {
+		inProcessAuthCache.Delete(key)
+		return "", false, false
+	}
+	if invalidatedAt := LastInvalidatedAt(systemKey); !invalidatedAt.IsZero() && entry.insertedAt.Before(invalidatedAt) {
 		inProcessAuthCache.Delete(key)
 		return "", false, false
 	}
@@ -79,10 +91,12 @@ func setInProcessCache(systemKey, systemSecret, systemID string, valid bool) {
 	} else {
 		ttl = 1 * time.Minute
 	}
+	now := time.Now()
 	inProcessAuthCache.Store(key, &authCacheEntry{
-		systemID:  systemID,
-		valid:     valid,
-		expiresAt: time.Now().Add(ttl),
+		systemID:   systemID,
+		valid:      valid,
+		insertedAt: now,
+		expiresAt:  now.Add(ttl),
 	})
 }
 
diff --git a/collect/middleware/auth_invalidator.go b/collect/middleware/auth_invalidator.go
new file mode 100644
index 000000000..c567ceb20
--- /dev/null
+++ b/collect/middleware/auth_invalidator.go
@@ -0,0 +1,190 @@
+/*
+ * Copyright (C) 2026 Nethesis S.r.l.
+ * http://www.nethesis.it - info@nethesis.it
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * author: Edoardo Spadoni <edoardo.spadoni@nethesis.it>
+ */
+
+package middleware
+
+import (
+	"context"
+	"crypto/hmac"
+	"crypto/sha256"
+	"encoding/hex"
+	"regexp"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/redis/go-redis/v9"
+
+	"github.com/nethesis/my/collect/configuration"
+	"github.com/nethesis/my/collect/logger"
+	"github.com/nethesis/my/collect/queue"
+)
+
+// systemKeyFormat pins the shape of a legitimate system_key so a malicious or
+// misconfigured publisher cannot smuggle Redis SCAN globs (`*`, `?`, `[…]`) or
+// other metacharacters into the invalidation payload. A `*` payload would
+// otherwise expand to a SCAN pattern matching every cached credential and
+// produce a thundering-herd auth lookup against Postgres on the next request
+// from each appliance.
+var systemKeyFormat = regexp.MustCompile(`^NETH(-[A-F0-9]{4}){9}$`)
+
+// systemAuthInvalidationChannelBase is the channel namespace; APP_ENV is
+// appended at runtime so a Redis instance shared across deployments stops
+// cross-pollinating invalidations.
+const systemAuthInvalidationChannelBase = "my:auth:invalidate"
+
+// lastInvalidatedAt tracks the wall-clock moment when each system_key was last
+// invalidated. checkInProcessCache (auth.go) consults this map to drop entries
+// inserted before the invalidation arrived: sync.Map.Range used by
+// purgeSystemAuthCache is not a snapshot, so a request that lands while the
+// purge is in flight could install a stale entry that Range never sees. The
+// timestamp closes that race without taking a global lock.
+var lastInvalidatedAt sync.Map
+
+// LastInvalidatedAt returns the most recent invalidation time for the given
+// system_key, or the zero time if none was ever recorded.
+func LastInvalidatedAt(systemKey string) time.Time {
+	if v, ok := lastInvalidatedAt.Load(systemKey); ok {
+		if t, ok := v.(time.Time); ok {
+			return t
+		}
+	}
+	return time.Time{}
+}
+
+// authInvalidationChannel returns the env-namespaced channel name. Must match
+// what backend publishes on (see backend/cache/system_auth.go).
+func authInvalidationChannel() string {
+	env := configuration.Config.AppEnv
+	if env == "" {
+		env = "dev"
+	}
+	return systemAuthInvalidationChannelBase + ":" + env
+}
+
+// verifyInvalidationPayload accepts either a bare system_key (when no shared
+// HMAC secret is configured locally) or `<systemKey>|<hex(hmac)>` and returns
+// the validated system_key. Mismatched HMAC, missing HMAC when one is
+// expected, or invalid system_key shape all return ("", false) so the caller
+// drops the message.
+func verifyInvalidationPayload(payload string) (string, bool) {
+	secret := configuration.Config.InternalHMACSecret
+	parts := strings.SplitN(strings.TrimSpace(payload), "|", 2)
+	systemKey := parts[0]
+	if !systemKeyFormat.MatchString(systemKey) {
+		return "", false
+	}
+	if secret == "" {
+		// No verification key configured — accept legacy bare-system_key
+		// payloads. A future hardening can flip this to "reject if no HMAC".
+		return systemKey, true
+	}
+	if len(parts) != 2 {
+		return "", false
+	}
+	expected, err := hex.DecodeString(parts[1])
+	if err != nil {
+		return "", false
+	}
+	mac := hmac.New(sha256.New, []byte(secret))
+	mac.Write([]byte(systemKey))
+	if !hmac.Equal(expected, mac.Sum(nil)) {
+		return "", false
+	}
+	return systemKey, true
+}
+
+// StartAuthInvalidator subscribes to the cross-service auth invalidation
+// channel and purges every cached credential entry for a system as soon
+// as backend signals the change. Without this hook the cache naturally
+// expires within SystemAuthCacheTTL (default 10 minutes); the bus makes
+// the propagation sub-second.
+//
+// The subscriber runs in its own goroutine and stops when ctx is done.
+// A Redis outage logs a warning and disables the bus — natural TTL
+// expiration still catches up with the change eventually.
+func StartAuthInvalidator(ctx context.Context) {
+	rdb := queue.GetClient()
+	if rdb == nil {
+		logger.Warn().Msg("Redis unavailable, auth invalidation bus disabled")
+		return
+	}
+
+	channel := authInvalidationChannel()
+	pubsub := rdb.Subscribe(ctx, channel)
+	go func() {
+		defer func() {
+			if err := pubsub.Close(); err != nil {
+				logger.Warn().Err(err).Msg("failed to close auth invalidation subscription")
+			}
+		}()
+		ch := pubsub.Channel()
+		logger.Info().Str("channel", channel).Msg("auth invalidation bus started")
+		for {
+			select {
+			case <-ctx.Done():
+				return
+			case msg, ok := <-ch:
+				if !ok {
+					return
+				}
+				systemKey, ok := verifyInvalidationPayload(msg.Payload)
+				if !ok {
+					logger.Warn().
+						Int("payload_bytes", len(msg.Payload)).
+						Msg("auth invalidation: dropping payload with bad shape or signature")
+					continue
+				}
+				// Stamp the invalidation moment BEFORE purging so any
+				// concurrent cache insert is detectable by checkInProcessCache.
+				lastInvalidatedAt.Store(systemKey, time.Now())
+				purgeSystemAuthCache(ctx, rdb, systemKey)
+			}
+		}
+	}()
+}
+
+// purgeSystemAuthCache drops every cached credential entry for the given
+// system_key from both the in-process sync.Map (see auth.go) and from
+// Redis. In-process entries are keyed as `<systemKey>:<secretHash>`;
+// Redis entries use `auth:system:<systemKey>:<secretHash>`.
+func purgeSystemAuthCache(ctx context.Context, rdb *redis.Client, systemKey string) {
+	// In-process: iterate and delete every entry whose key starts with
+	// the system_key prefix. sync.Map.Range is safe against concurrent
+	// modification.
+	prefix := systemKey + ":"
+	inProcessAuthCache.Range(func(key, _ any) bool {
+		if k, ok := key.(string); ok && strings.HasPrefix(k, prefix) {
+			inProcessAuthCache.Delete(k)
+		}
+		return true
+	})
+
+	// Redis: SCAN through every auth cache entry for this system_key and
+	// delete it. Use a modest batch size so the scan does not block
+	// other commands in a busy Redis.
+	pattern := "auth:system:" + systemKey + ":*"
+	iter := rdb.Scan(ctx, 0, pattern, 100).Iterator()
+	count := 0
+	for iter.Next(ctx) {
+		if err := rdb.Del(ctx, iter.Val()).Err(); err != nil {
+			logger.Warn().Err(err).Str("key", iter.Val()).Msg("auth invalidation: delete failed")
+			continue
+		}
+		count++
+	}
+	if err := iter.Err(); err != nil {
+		logger.Warn().Err(err).Str("system_key", systemKey).Msg("auth invalidation: scan failed")
+	}
+
+	logger.Info().
+		Str("system_key", systemKey).
+		Int("redis_keys_deleted", count).
+		Msg("auth cache invalidated")
+}
diff --git a/collect/storage/s3.go b/collect/storage/s3.go
new file mode 100644
index 000000000..a697d2d6d
--- /dev/null
+++ b/collect/storage/s3.go
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2026 Nethesis S.r.l.
+ * http://www.nethesis.it - info@nethesis.it
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * author: Edoardo Spadoni <edoardo.spadoni@nethesis.it>
+ */
+
+// Package storage provides an S3 client for the DigitalOcean Spaces bucket
+// that holds appliance configuration backups.
+//
+// The client is configured via BACKUP_S3_* environment variables and exposes
+// a singleton for use by the backup ingest and restore handlers. Virtual-
+// hosted addressing is the default (required by Spaces and most managed S3
+// providers); path-style can be forced for local S3 emulators via
+// BACKUP_S3_USE_PATH_STYLE=true.
+package storage
+
+import (
+	"context"
+	"fmt"
+	"sync"
+
+	"github.com/aws/aws-sdk-go-v2/aws"
+	awsconfig "github.com/aws/aws-sdk-go-v2/config"
+	"github.com/aws/aws-sdk-go-v2/credentials"
+	"github.com/aws/aws-sdk-go-v2/service/s3"
+
+	"github.com/nethesis/my/collect/configuration"
+)
+
+var (
+	backupClient     *s3.Client
+	backupPresigner  *s3.PresignClient
+	backupClientErr  error
+	backupClientOnce sync.Once
+)
+
+// BackupClient returns a singleton S3 client wired to the configured backup
+// store, along with a presigner for generating short-lived download URLs.
+// The first call initializes both; subsequent calls return the cached values.
+func BackupClient(ctx context.Context) (*s3.Client, *s3.PresignClient, error) {
+	backupClientOnce.Do(func() {
+		backupClient, backupPresigner, backupClientErr = buildBackupClient(ctx)
+	})
+	return backupClient, backupPresigner, backupClientErr
+}
+
+func buildBackupClient(ctx context.Context) (*s3.Client, *s3.PresignClient, error) {
+	if configuration.Config.BackupS3Endpoint == "" {
+		return nil, nil, fmt.Errorf("BACKUP_S3_ENDPOINT is not set")
+	}
+	if configuration.Config.BackupS3AccessKey == "" || configuration.Config.BackupS3SecretKey == "" {
+		return nil, nil, fmt.Errorf("BACKUP_S3_ACCESS_KEY and BACKUP_S3_SECRET_KEY must be set")
+	}
+
+	creds := credentials.NewStaticCredentialsProvider(
+		configuration.Config.BackupS3AccessKey,
+		configuration.Config.BackupS3SecretKey,
+		"",
+	)
+
+	cfg, err := awsconfig.LoadDefaultConfig(ctx,
+		awsconfig.WithRegion(configuration.Config.BackupS3Region),
+		awsconfig.WithCredentialsProvider(creds),
+	)
+	if err != nil {
+		return nil, nil, fmt.Errorf("load S3 config: %w", err)
+	}
+
+	client := s3.NewFromConfig(cfg, func(o *s3.Options) {
+		o.BaseEndpoint = aws.String(configuration.Config.BackupS3Endpoint)
+		o.UsePathStyle = configuration.Config.BackupS3UsePathStyle
+	})
+
+	presigner := s3.NewPresignClient(client)
+
+	return client, presigner, nil
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index d43724865..6dcd8898a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,18 +1,18 @@
 # My Nethesis - Local Development Infrastructure
 #
-# 🚀 READY! Access your application at: http://localhost:9090
+# Access the application at http://localhost:9090 once the stack is up.
 #
-# 🌐 Service URLs:
+# Service URLs:
 #   - Main App:    http://localhost:9090
 #   - Backend:     http://localhost:18080/api/health
 #   - Collect:     http://localhost:18081/api/health
 #   - Frontend:    http://localhost:13000
 #
-# 💾 Databases:
+# Databases:
 #   - PostgreSQL:  localhost:15432 (user: noc_user, db: noc)
 #   - Redis:       localhost:16379
 #
-# 🛠️ Management:
+# Management:
 #   - Start:       docker-compose up -d  OR  podman-compose up -d
 #   - Stop:        docker-compose down   OR  podman-compose down
 #   - Logs:        docker-compose logs -f [service]  OR  podman-compose logs -f [service]
diff --git a/docs/docs/systems/backups.md b/docs/docs/systems/backups.md
new file mode 100644
index 000000000..297b16662
--- /dev/null
+++ b/docs/docs/systems/backups.md
@@ -0,0 +1,107 @@
+---
+sidebar_position: 4
+---
+
+# Configuration Backups
+
+Each registered system — NethServer 8 (NS8) and NethSecurity — uploads an encrypted snapshot of its configuration to MY on a daily schedule. This page describes what is stored, how the data is protected, and how operators interact with the backup subsystem.
+
+## Overview
+
+Backups are **end-to-end encrypted on the appliance** before they are uploaded. MY stores the ciphertext together with a short set of metadata (size, SHA-256, upload timestamp, appliance version) on an S3-compatible object store; the server never sees the plaintext of the backup and cannot decrypt it.
+
+```
+┌──────────────┐   GPG-encrypted blob     ┌──────────────┐    PutObject    ┌──────────────┐
+│  Appliance   │ ───────────────────────► │   collect    │ ───────────────►│   S3 bucket  │
+│ (NS8 / NSEC) │   POST /systems/backups  │  (ingest)    │                 │  (DO Spaces, │
+└──────────────┘   HTTP Basic auth        └──────────────┘                 │   AWS S3, …) │
+                                                                           └──────▲───────┘
+                                                                                  │
+                                                                                  │ presigned URL
+                                                                                  │
+                                                                          ┌──────────────┐
+                                                                          │   backend    │
+                                                                          │  (list/read) │
+                                                                          └──────────────┘
+```
+
+## Authentication and access control
+
+Uploads use **HTTP Basic auth** with the same `system_key:system_secret` pair the appliance already uses for inventory and heartbeat (see [system registration](registration)). A system can only write or read its own prefix on the bucket — cross-tenant access is refused server-side.
+
+Reads performed by users go through `backend` with the regular Logto-issued JWT and the same RBAC rules that gate `GET /systems/:id`: a user sees a system's backups only if the user's organization owns that system.
+
+## Storage layout
+
+Every backup object is keyed as:
+
+```
+{org_id}/{system_key}/{backup_id}.{ext}
+```
+
+- `org_id` is the organization that owns the system.
+- `system_key` is the stable user-facing identifier (`NETH-…`) the appliance authenticates with — chosen over the internal UUID so operators browsing a raw bucket listing can recognise each system at a glance.
+- `backup_id` is a time-ordered UUIDv7 generated at upload time.
+- `ext` reflects the compression / encryption format detected from the appliance-provided filename (`.tar.gz`, `.tar.xz`, `.gpg`, `.bin`).
+
+Per-object metadata travels as standard `x-amz-meta-*` headers:
+
+| Header                   | Meaning                                                                 |
+|--------------------------|-------------------------------------------------------------------------|
+| `x-amz-meta-sha256`      | SHA-256 of the encrypted blob, computed by `collect` during the stream. |
+| `x-amz-meta-filename`    | User-facing name provided by the appliance (`X-Filename` header).       |
+| `x-amz-meta-uploader-ip` | Peer address seen by `collect` at ingest — not forgeable via proxies.   |
+
+## Retention and quotas
+
+The ingest path enforces three independent caps per system:
+
+| Setting                          | Default     | Meaning                                           |
+|----------------------------------|-------------|---------------------------------------------------|
+| `BACKUP_MAX_PER_SYSTEM`          | 10          | Maximum number of backups kept per system.        |
+| `BACKUP_MAX_SIZE_PER_SYSTEM`     | 500&nbsp;MB | Maximum total bytes stored per system.            |
+| `BACKUP_MAX_UPLOAD_SIZE`         | 2&nbsp;GB   | Hard limit on a single upload.                    |
+
+A per-organization ceiling is also enforced:
+
+| Setting                      | Default    | Meaning                                                   |
+|------------------------------|------------|-----------------------------------------------------------|
+| `BACKUP_MAX_SIZE_PER_ORG`    | 100&nbsp;GB | Total bytes across every system in the same organization. Set to `0` to disable (logged as a warning at startup). |
+
+When either the count or size threshold is exceeded the oldest object under the system's prefix is pruned until the backup fits. Pruning is serialised by a Redis lock so concurrent uploads from the same appliance cannot race over the victim.
+
+Appliance uploads are additionally rate-limited per system (default 6 per minute, 60 per hour). Legitimate NS8 and NethSecurity installations run on a daily timer and never hit these limits; the caps exist to contain flood-style abuse.
+
+## Deletion and GDPR
+
+A **soft delete** keeps the backups in place: the system row is flagged with `deleted_at` but can still be restored via the UI, and its backups must survive so the restore is useful. A **hard destroy** is irreversible and runs a GDPR-aligned erasure — every object under the system's `{org_id}/{system_key}/` prefix is removed from the bucket before the database row is dropped, whether the system was previously soft-deleted or not. If the system was previously reassigned across organizations, every prior `org_id` prefix is swept too, so a partial cleanup failure during a past reassignment cannot leak ciphertext past the destroy. If the storage cleanup fails the destroy is refused so the operator can retry; no orphan ciphertext is ever left behind under a destroyed system's prefix. Credential changes (secret rotation, soft delete) invalidate every cached auth entry on `collect` within a second through a cross-service Redis pub/sub bus.
+
+## Cross-organization reassignment
+
+When a system is moved from one organization to another, its backups
+follow the new owner: everything under the previous owner's prefix is
+copied to the new owner's prefix before the change is committed, and
+the previous prefix is then cleared. The full mechanics — what carries
+over, who can trigger it, what the previous owner sees after the
+move — are documented in [Reassigning a system to another organization](org-reassignment).
+
+## Managing backups
+
+API endpoints exposed by `backend` for administrators:
+
+| Method | Path                                                      | Purpose                                           |
+|--------|-----------------------------------------------------------|---------------------------------------------------|
+| `GET`  | `/api/systems/:id/backups`                                | List every backup for the system, with usage counters. |
+| `GET`  | `/api/systems/:id/backups/:backup_id/download`            | Return a presigned download URL (5 min TTL).      |
+| `DELETE` | `/api/systems/:id/backups/:backup_id`                   | Delete a specific backup.                          |
+
+Presigned URLs are minted server-side and carry no authentication — treat them as short-lived bearer tokens and do not share them.
+
+A UI for listing, downloading, and deleting backups lives under the system detail view.
+
+## Related
+
+- [System registration](registration) — how an appliance obtains the credentials used for backup uploads.
+- [Reassigning a system to another organization](org-reassignment) — what happens to backups when a system changes owner.
+- [`collect/README.md`](https://github.com/NethServer/my/blob/main/collect/README.md) — storage configuration (`BACKUP_S3_*`) and a copy-paste `curl` recipe for simulating an appliance upload.
+- [`backend/README.md`](https://github.com/NethServer/my/blob/main/backend/README.md) — matching read-side storage configuration.
diff --git a/docs/docs/systems/management.md b/docs/docs/systems/management.md
index 759e717f1..bfb1b5557 100644
--- a/docs/docs/systems/management.md
+++ b/docs/docs/systems/management.md
@@ -164,6 +164,15 @@ See [Inventory and Heartbeat](inventory-heartbeat) for details.
    - Notes
 4. Click **Save system**
 
+:::tip
+Changing the **Organization** moves the system to a different owner.
+The system's backups, alert history, and inventory follow the new
+owner; the previous owner loses access immediately. See
+[Reassigning a system to another organization](org-reassignment) for
+the full behaviour, who is allowed to do it, and what happens to
+silences and app assignments.
+:::
+
 ### Regenerating System Secret
 
 If the `system_secret` is compromised or lost:
diff --git a/docs/docs/systems/org-reassignment.md b/docs/docs/systems/org-reassignment.md
new file mode 100644
index 000000000..5dd1d3409
--- /dev/null
+++ b/docs/docs/systems/org-reassignment.md
@@ -0,0 +1,126 @@
+---
+sidebar_position: 5
+---
+
+# Reassigning a system to another organization
+
+Sometimes a system needs to move from one customer to another — a managed
+service provider hands a server back to its end customer, an organization
+is restructured, or a system was originally created under the wrong tenant.
+My supports this operation natively: the system carries its full state to
+the new owner, and the previous owner loses access immediately.
+
+## When you would do this
+
+- A reseller or distributor takes over (or returns) a customer's system.
+- A system was registered under the wrong organization at setup time.
+- An organization is being merged or split, and its systems must follow.
+
+## What gets moved
+
+When you change a system's organization, My moves all the data that
+*belongs to the system* to the new owner, and leaves the previous owner's
+own data alone:
+
+| Data | What happens |
+|---|---|
+| **Configuration backups** | Copied to the new owner's storage area before the change is committed; the previous owner's copies are removed. |
+| **Alert history** | The system's full alert history follows the system, so the new owner sees what was happening before the handover. |
+| **System-specific silences** | Single-purpose silences (silencing all alerts of that one system) are dropped — the new owner can reapply them if needed. Broader silences set up by the previous owner (for example "silence disk-full alerts on this system during maintenance") are left in place; they expire on their own. |
+| **Application assignments** | All app-to-organization assignments on the system are cleared. The new owner re-assigns the apps it cares about from the regular Applications view. |
+| **Inventory, heartbeat, system identity** | Untouched — `system_key`, the secret, the inventory history all carry over. Appliances do not need to re-register. |
+
+The change is **atomic** for the data that matters: either everything is
+in place under the new owner, or the change is refused outright with no
+side effect. Cleanup of the previous owner's residual data runs after the
+flip and is best-effort — if anything is left behind, it does not affect
+the new owner's view.
+
+## How to reassign
+
+### From the UI
+
+1. Open the system detail page.
+2. Edit the system and pick a different **Organization** from the dropdown.
+3. Save. Within a few seconds the system shows the new owner and all the
+   data above is in place.
+
+### From the API
+
+```bash
+curl -X PUT https://my.example.com/api/systems/$SYSTEM_ID \
+     -H "Authorization: Bearer $JWT" \
+     -H "Content-Type: application/json" \
+     -d '{"name":"unchanged","organization_id":"<new-logto-id>"}'
+```
+
+A reassignment with backups under retention typically completes in a few
+seconds. With heavier workloads it can take longer — the request stays
+open until the move is fully on disk on the new owner's side.
+
+## Who can do it
+
+- **Owner** can reassign any system to any existing organization.
+- **Distributor** and **Reseller** can reassign systems within their own
+  hierarchy — they cannot move a system *out* of their scope to an
+  organization they do not manage.
+- **Customer** cannot reassign systems.
+
+A reassignment to an organization that does not exist is rejected with a
+`403 access denied` — there is no path that strands a system under an
+unreachable organization.
+
+## What the previous owner sees afterwards
+
+As soon as the change is committed:
+
+- The system disappears from the previous owner's "Systems" list.
+- The previous owner's API calls against the system return `404` (the
+  system is no longer in their scope).
+- Any direct link they had to the system's backup, alert history, or
+  detail page returns `404` too.
+
+This is the same behaviour as if the system had been deleted from the
+previous owner's perspective — the difference is the data still exists,
+just under the new owner.
+
+## Concurrent edits
+
+If two administrators try to reassign the same system at the same moment,
+only the first one succeeds. The second one receives a clear
+`409 system reassignment is already in progress` and can retry once the
+first reassignment has completed.
+
+This protects the system from inconsistent half-state where its data
+ends up split between two destinations.
+
+## Common questions
+
+**Can I move a system back to its original owner?**
+Yes. Reassignment is symmetric — moving from A to B and back to A works
+the same way both times.
+
+**Will the system continue working during the reassignment?**
+Yes. The appliance keeps sending inventory, heartbeats, and backups
+without interruption. Uploads in flight at the moment of the change land
+correctly under the new owner once the reassignment is committed.
+
+**What about an active support tunnel session?**
+A support session that was already open continues until it expires
+(default 24h). New sessions are started by the new owner.
+
+**Does the system need to be online to be reassigned?**
+No. Reassignment operates on the platform-side data only; the appliance
+state is not touched.
+
+**Can I undo a reassignment?**
+Reassignment is recorded in an audit trail visible to the platform Owner.
+There is no one-click undo, but reassigning back to the original owner is
+the standard way to revert; the data follows the system in both
+directions.
+
+## Related
+
+- [Systems management](management) — the full lifecycle of a system.
+- [Configuration backups](backups) — what is stored, how it's protected,
+  and how the per-organization quota is computed.
diff --git a/docs/i18n/it/docusaurus-plugin-content-docs/current/systems/backups.md b/docs/i18n/it/docusaurus-plugin-content-docs/current/systems/backups.md
new file mode 100644
index 000000000..d4cf0e6cf
--- /dev/null
+++ b/docs/i18n/it/docusaurus-plugin-content-docs/current/systems/backups.md
@@ -0,0 +1,108 @@
+---
+sidebar_position: 4
+---
+
+# Backup di configurazione
+
+Ogni sistema registrato — NethServer 8 (NS8) e NethSecurity — carica ogni giorno un'istantanea cifrata della propria configurazione su MY. Questa pagina descrive che cosa viene conservato, come i dati sono protetti e come gli operatori interagiscono col sottosistema di backup.
+
+## Panoramica
+
+I backup sono **cifrati end-to-end sull'appliance** prima dell'upload. MY memorizza il ciphertext insieme a un insieme ridotto di metadati (dimensione, SHA-256, timestamp di upload, versione dell'appliance) su un object store S3-compatible; il server non vede mai il plaintext e non è in grado di decifrare il contenuto.
+
+```
+┌──────────────┐   blob GPG-cifrato       ┌──────────────┐    PutObject    ┌──────────────┐
+│  Appliance   │ ───────────────────────► │   collect    │ ───────────────►│   Bucket S3  │
+│ (NS8 / NSEC) │   POST /systems/backups  │  (ingest)    │                 │ (DO Spaces,  │
+└──────────────┘   HTTP Basic auth        └──────────────┘                 │  AWS S3, …)  │
+                                                                           └──────▲───────┘
+                                                                                  │
+                                                                                  │ presigned URL
+                                                                                  │
+                                                                          ┌──────────────┐
+                                                                          │   backend    │
+                                                                          │  (list/read) │
+                                                                          └──────────────┘
+```
+
+## Autenticazione e controllo accessi
+
+Gli upload usano **HTTP Basic auth** con la stessa coppia `system_key:system_secret` che l'appliance usa già per inventario e heartbeat (vedi [registrazione del sistema](registration)). Un sistema può scrivere o leggere solo il proprio prefisso sul bucket — l'accesso cross-tenant viene rifiutato lato server.
+
+Le letture da parte degli utenti passano per `backend` con il normale JWT Logto e obbediscono alle stesse regole RBAC di `GET /systems/:id`: un utente vede i backup di un sistema soltanto se l'organizzazione a cui appartiene ne è proprietaria.
+
+## Layout dello storage
+
+Ogni oggetto di backup ha la chiave:
+
+```
+{org_id}/{system_key}/{backup_id}.{ext}
+```
+
+- `org_id` è l'organizzazione che possiede il sistema.
+- `system_key` è l'identificatore utente stabile (`NETH-…`) con cui l'appliance si autentica — scelto rispetto all'UUID interno per permettere agli operatori di riconoscere a colpo d'occhio ogni sistema sfogliando il bucket.
+- `backup_id` è uno UUIDv7 ordinabile nel tempo, generato al momento dell'upload.
+- `ext` riflette il formato di compressione/cifratura rilevato dal filename che l'appliance invia (`.tar.gz`, `.tar.xz`, `.gpg`, `.bin`).
+
+I metadati per oggetto viaggiano come header `x-amz-meta-*` standard:
+
+| Header                   | Significato                                                              |
+|--------------------------|--------------------------------------------------------------------------|
+| `x-amz-meta-sha256`      | SHA-256 del blob cifrato, calcolato da `collect` durante lo streaming.   |
+| `x-amz-meta-filename`    | Nome user-facing fornito dall'appliance tramite l'header `X-Filename`.   |
+| `x-amz-meta-uploader-ip` | Indirizzo del peer visto da `collect` — non falsificabile via proxy.     |
+
+## Retention e quote
+
+L'ingest applica tre limiti indipendenti per sistema:
+
+| Parametro                          | Default     | Significato                                        |
+|------------------------------------|-------------|----------------------------------------------------|
+| `BACKUP_MAX_PER_SYSTEM`            | 10          | Numero massimo di backup mantenuti per sistema.    |
+| `BACKUP_MAX_SIZE_PER_SYSTEM`       | 500&nbsp;MB | Totale byte massimo per sistema.                   |
+| `BACKUP_MAX_UPLOAD_SIZE`           | 2&nbsp;GB   | Limite duro per singolo upload.                    |
+
+È attivo anche un tetto per-organizzazione:
+
+| Parametro                      | Default    | Significato                                                   |
+|--------------------------------|------------|---------------------------------------------------------------|
+| `BACKUP_MAX_SIZE_PER_ORG`      | 100&nbsp;GB | Totale byte aggregato su tutti i sistemi della stessa organizzazione. Impostare a `0` per disabilitare (viene loggato un warning all'avvio). |
+
+Quando una delle soglie (count o size) viene superata, l'oggetto più vecchio sotto il prefisso del sistema viene eliminato finché il backup non rientra nei limiti. La pruning è serializzata da un lock Redis: upload concorrenti dallo stesso appliance non possono mai correre sullo stesso oggetto vittima.
+
+Gli upload dell'appliance sono inoltre sottoposti a rate limit per sistema (default 6 al minuto, 60 all'ora). NS8 e NethSecurity in produzione caricano su timer giornaliero e non raggiungono mai queste soglie; il limite serve a contenere abusi di tipo flood.
+
+## Eliminazione e GDPR
+
+Un **soft delete** lascia i backup al loro posto: la riga del sistema viene marcata con `deleted_at` ma può ancora essere ripristinata dalla UI, e i suoi backup devono sopravvivere perché il ripristino sia utile. Un **hard destroy** è irreversibile ed esegue un'erasure GDPR-compliant: ogni oggetto sotto il prefisso `{org_id}/{system_key}/` del sistema viene rimosso dal bucket prima che la riga nel database venga cancellata, sia che il sistema sia stato soft-deleted in precedenza sia che no. Se il sistema era stato in passato riassegnato fra organizzazioni, il destroy spazza anche tutti i prefissi `org_id` precedenti, quindi un cleanup parziale fallito durante una riassegnazione passata non lascia ciphertext residuo dopo il destroy. Se il cleanup dello storage fallisce, il destroy viene rifiutato in modo che l'operatore possa riprovare; nessun ciphertext orfano rimane mai sotto il prefisso di un sistema distrutto. Le modifiche delle credenziali (rotazione del secret, soft delete) invalidano ogni cache di auth su `collect` entro un secondo, tramite un bus Redis pub/sub cross-service.
+
+## Riassegnazione fra organizzazioni
+
+Quando un sistema viene spostato da un'organizzazione a un'altra, i suoi
+backup seguono il nuovo proprietario: tutto ciò che si trovava sotto il
+prefisso del precedente proprietario viene copiato sotto il prefisso
+del nuovo prima che il cambio venga confermato, e il prefisso precedente
+viene poi svuotato. La meccanica completa — cosa segue il sistema, chi
+può triggerare la riassegnazione, cosa vede il proprietario precedente
+dopo lo spostamento — è documentata in [Riassegnare un sistema a un'altra organizzazione](org-reassignment).
+
+## Gestione dei backup
+
+Endpoint API esposti dal `backend` per gli amministratori:
+
+| Metodo | Path                                                      | Scopo                                              |
+|--------|-----------------------------------------------------------|----------------------------------------------------|
+| `GET`  | `/api/systems/:id/backups`                                | Elenca tutti i backup del sistema, con contatori d'uso. |
+| `GET`  | `/api/systems/:id/backups/:backup_id/download`            | Restituisce una URL di download presigned (TTL 5 min). |
+| `DELETE` | `/api/systems/:id/backups/:backup_id`                   | Elimina un singolo backup.                         |
+
+Le URL presigned vengono generate lato server e non veicolano alcuna autenticazione — trattarle come bearer token a vita breve e non condividerle.
+
+La UI per listare, scaricare ed eliminare i backup si trova nella vista di dettaglio del sistema.
+
+## Riferimenti
+
+- [Registrazione del sistema](registration) — come un'appliance ottiene le credenziali usate per gli upload di backup.
+- [Riassegnare un sistema a un'altra organizzazione](org-reassignment) — cosa succede ai backup quando un sistema cambia proprietario.
+- [`collect/README.md`](https://github.com/NethServer/my/blob/main/collect/README.md) — configurazione dello storage (`BACKUP_S3_*`) e una ricetta `curl` per simulare un upload appliance.
+- [`backend/README.md`](https://github.com/NethServer/my/blob/main/backend/README.md) — stessa configurazione lato letture.
diff --git a/docs/i18n/it/docusaurus-plugin-content-docs/current/systems/management.md b/docs/i18n/it/docusaurus-plugin-content-docs/current/systems/management.md
index 60f9a594e..9780b9e9f 100644
--- a/docs/i18n/it/docusaurus-plugin-content-docs/current/systems/management.md
+++ b/docs/i18n/it/docusaurus-plugin-content-docs/current/systems/management.md
@@ -155,6 +155,15 @@ Per modificare un sistema:
 4. Aggiorna i campi desiderati (nome, descrizione, organizzazione)
 5. Clicca su **Salva**
 
+:::tip
+Cambiare l'**Organizzazione** sposta il sistema a un proprietario
+diverso. I backup, lo storico allarmi e l'inventario seguono il nuovo
+proprietario; il precedente perde l'accesso immediatamente. Per il
+comportamento completo, chi è autorizzato a farlo, e cosa succede a
+silence e assegnazioni applicazioni, vedi
+[Riassegnare un sistema a un'altra organizzazione](org-reassignment).
+:::
+
 ### Rigenerazione Secret
 
 Se il secret del sistema è stato perso:
diff --git a/docs/i18n/it/docusaurus-plugin-content-docs/current/systems/org-reassignment.md b/docs/i18n/it/docusaurus-plugin-content-docs/current/systems/org-reassignment.md
new file mode 100644
index 000000000..08f50f6bf
--- /dev/null
+++ b/docs/i18n/it/docusaurus-plugin-content-docs/current/systems/org-reassignment.md
@@ -0,0 +1,133 @@
+---
+sidebar_position: 5
+---
+
+# Riassegnare un sistema a un'altra organizzazione
+
+A volte un sistema deve passare da un cliente a un altro — un MSP
+restituisce un server al cliente finale, l'organizzazione viene
+riorganizzata, oppure un sistema era stato registrato sotto il tenant
+sbagliato. MY supporta questa operazione nativamente: il sistema porta
+con sé tutti i propri dati al nuovo proprietario, e quello precedente
+perde l'accesso immediatamente.
+
+## Quando usarla
+
+- Un reseller o distributor prende in carico (o restituisce) un sistema
+  di un cliente.
+- Un sistema è stato registrato sotto la organizzazione sbagliata in
+  fase di setup.
+- Un'organizzazione viene fusa o divisa, e i suoi sistemi devono seguire.
+
+## Cosa viene spostato
+
+Quando cambi l'organizzazione di un sistema, MY sposta tutti i dati che
+*appartengono al sistema* al nuovo proprietario, lasciando intatti i dati
+del proprietario precedente:
+
+| Dato | Cosa succede |
+|---|---|
+| **Backup di configurazione** | Copiati nell'area di storage del nuovo proprietario prima che il cambio sia confermato; le copie del precedente proprietario vengono rimosse. |
+| **Storico allarmi** | Lo storico completo segue il sistema, così il nuovo proprietario vede ciò che stava accadendo prima del passaggio di consegne. |
+| **Silence specifiche del sistema** | I silence a singolo scopo (che silenziano tutti gli allarmi di quel solo sistema) vengono rimossi — il nuovo proprietario può riconfigurarli se gli servono. I silence più ampi creati dal precedente proprietario (ad esempio "silenzia gli allarmi disco pieno su questo sistema durante una manutenzione") restano dove sono e scadono per conto loro. |
+| **Assegnazioni delle applicazioni** | Tutte le assegnazioni app→organizzazione sul sistema vengono azzerate. Il nuovo proprietario riassegna le app che gli interessano dalla normale vista Applicazioni. |
+| **Inventory, heartbeat, identità del sistema** | Intatti — `system_key`, il secret e l'intero storico inventari restano. Le appliance non devono ri-registrarsi. |
+
+Il cambio è **atomico** sui dati che contano: o tutto è in posizione
+sotto il nuovo proprietario, oppure il cambio viene rifiutato senza
+effetti collaterali. La pulizia dei dati residui del proprietario
+precedente avviene dopo lo switch ed è best-effort: eventuali residui
+non influenzano in alcun modo la vista del nuovo proprietario.
+
+## Come riassegnare
+
+### Dalla UI
+
+1. Apri la pagina di dettaglio del sistema.
+2. Modifica il sistema e seleziona una **Organizzazione** diversa dal
+   menu a tendina.
+3. Salva. In pochi secondi il sistema mostra il nuovo proprietario e
+   tutti i dati di cui sopra sono già in posizione.
+
+### Dall'API
+
+```bash
+curl -X PUT https://my.example.com/api/systems/$SYSTEM_ID \
+     -H "Authorization: Bearer $JWT" \
+     -H "Content-Type: application/json" \
+     -d '{"name":"unchanged","organization_id":"<new-logto-id>"}'
+```
+
+Una riassegnazione con backup nei limiti di retention si completa
+tipicamente in pochi secondi. Con carichi più pesanti può richiedere
+più tempo — la richiesta resta aperta finché lo spostamento non è
+completamente concluso lato nuovo proprietario.
+
+## Chi può farlo
+
+- L'**Owner** può riassegnare qualunque sistema a qualunque
+  organizzazione esistente.
+- **Distributor** e **Reseller** possono riassegnare sistemi all'interno
+  della propria gerarchia: non possono spostare un sistema *fuori* dal
+  proprio scope verso un'organizzazione che non gestiscono.
+- Il **Customer** non può riassegnare sistemi.
+
+Una riassegnazione verso un'organizzazione che non esiste viene
+rifiutata con `403 access denied`: non c'è nessun percorso che possa
+lasciare un sistema sotto un'organizzazione irraggiungibile.
+
+## Cosa vede il proprietario precedente
+
+Non appena il cambio viene confermato:
+
+- Il sistema scompare dalla lista "Sistemi" del proprietario precedente.
+- Le chiamate API del proprietario precedente sul sistema rispondono
+  `404` (il sistema non è più nel suo scope).
+- Eventuali link diretti che aveva alla pagina di backup, allarmi o
+  dettaglio del sistema rispondono anch'essi `404`.
+
+Lo stesso comportamento di una cancellazione del sistema dal punto di
+vista del proprietario precedente — la differenza è che i dati esistono
+ancora, semplicemente sotto il nuovo proprietario.
+
+## Modifiche concorrenti
+
+Se due amministratori provano a riassegnare lo stesso sistema nello
+stesso momento, solo il primo riesce. Il secondo riceve un chiaro
+`409 system reassignment is already in progress` e può ritentare una
+volta che la prima riassegnazione è terminata.
+
+Questa protezione evita che il sistema finisca in uno stato a metà con
+i dati spezzati tra due destinazioni diverse.
+
+## Domande frequenti
+
+**Posso riportare il sistema al proprietario originale?**
+Sì. La riassegnazione è simmetrica — spostare da A a B e poi da B ad A
+funziona allo stesso modo nelle due direzioni.
+
+**Il sistema continua a funzionare durante la riassegnazione?**
+Sì. L'appliance continua a inviare inventari, heartbeat e backup senza
+interruzioni. Gli upload in volo nel momento del cambio atterrano
+correttamente sotto il nuovo proprietario una volta completato lo
+spostamento.
+
+**E una sessione di support tunnel attiva?**
+Una sessione di supporto già aperta continua finché non scade
+(default 24h). Le nuove sessioni vengono avviate dal nuovo proprietario.
+
+**Il sistema deve essere online per essere riassegnato?**
+No. La riassegnazione opera solo sui dati lato piattaforma; lo stato
+dell'appliance non viene toccato.
+
+**Posso annullare una riassegnazione?**
+Le riassegnazioni vengono registrate in un log di audit visibile
+all'Owner della piattaforma. Non c'è un undo a un click, ma riassegnare
+indietro al proprietario originale è il modo standard per farlo:
+i dati seguono il sistema in entrambe le direzioni.
+
+## Correlato
+
+- [Gestione dei sistemi](management) — l'intero ciclo di vita di un sistema.
+- [Backup di configurazione](backups) — cosa viene memorizzato, come è
+  protetto, e come si calcola la quota per organizzazione.
diff --git a/docs/sidebars.ts b/docs/sidebars.ts
index 20bdb869f..19e086808 100644
--- a/docs/sidebars.ts
+++ b/docs/sidebars.ts
@@ -28,6 +28,7 @@ const sidebars: SidebarsConfig = {
         'systems/management',
         'systems/registration',
         'systems/inventory-heartbeat',
+        'systems/backups',
       ],
     },
     {
diff --git a/frontend/.render-build-trigger b/frontend/.render-build-trigger
index 4ee864ad3..320733ced 100644
--- a/frontend/.render-build-trigger
+++ b/frontend/.render-build-trigger
@@ -2,9 +2,9 @@
 # This file is used to force Docker service rebuilds in PR previews
 # Modify LAST_UPDATE to trigger rebuilds
 
-LAST_UPDATE=2026-04-07T13:40:58Z
+LAST_UPDATE=2026-04-22T07:15:29Z
 
 # Instructions:
 # 1. To force rebuild of Docker services in a PR, update LAST_UPDATE
-# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-04-07T13:40:58Z/LAST_UPDATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)/" $f/.render-build-trigger
+# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-04-22T07:15:29Z
 # 2. Commit and push changes to trigger Docker rebuilds
diff --git a/frontend/src/components/systems/AdditionalServicesCard.vue b/frontend/src/components/systems/AdditionalServicesCard.vue
index 87e59e979..af6e0982f 100644
--- a/frontend/src/components/systems/AdditionalServicesCard.vue
+++ b/frontend/src/components/systems/AdditionalServicesCard.vue
@@ -65,7 +65,7 @@ const sortedServices = computed<ServiceItem[]>(() =>
 
 <template>
   <NeCard>
-    <div class="mb-4 flex items-center gap-4">
+    <div class="mb-4 flex h-10 items-center gap-4">
       <FontAwesomeIcon :icon="faFolderPlus" class="size-5 shrink-0" aria-hidden="true" />
       <NeHeading tag="h6">
         {{ $t('system_detail.additional_services').toUpperCase() }}
@@ -87,12 +87,12 @@ const sortedServices = computed<ServiceItem[]>(() =>
       <div
         v-for="service in sortedServices"
         :key="service.key"
-        class="flex items-center justify-between py-3"
+        class="flex items-center justify-between gap-2 py-4"
       >
         <span class="font-medium text-gray-900 dark:text-gray-50">
           {{ service.label }}
         </span>
-        <div class="flex items-center gap-2">
+        <div class="flex items-center gap-2 text-end text-gray-600 dark:text-gray-300">
           <FontAwesomeIcon
             :icon="service.enabled ? faCircleCheck : faCircleXmark"
             class="size-4"
@@ -103,7 +103,7 @@ const sortedServices = computed<ServiceItem[]>(() =>
             "
             aria-hidden="true"
           />
-          <span class="text-xs font-medium text-gray-600 dark:text-gray-300">
+          <span class="text-xs font-medium">
             {{ service.enabled ? $t('common.enabled') : $t('common.disabled') }}
           </span>
         </div>
diff --git a/frontend/src/components/systems/SystemBackupsPanel.vue b/frontend/src/components/systems/SystemBackupsPanel.vue
new file mode 100644
index 000000000..511c5a578
--- /dev/null
+++ b/frontend/src/components/systems/SystemBackupsPanel.vue
@@ -0,0 +1,329 @@
+<!--
+  Copyright (C) 2026 Nethesis S.r.l.
+  SPDX-License-Identifier: GPL-3.0-or-later
+-->
+
+<script setup lang="ts">
+import {
+  NeButton,
+  NeCard,
+  NeDropdown,
+  type NeDropdownItem,
+  NeEmptyState,
+  NeHeading,
+  NeInlineNotification,
+  NeSkeleton,
+  NeTable,
+  NeTableBody,
+  NeTableCell,
+  NeTableHead,
+  NeTableHeadCell,
+  NeTableRow,
+  NeTooltip,
+} from '@nethesis/vue-components'
+import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
+import {
+  faArrowsRotate,
+  faBoxArchive,
+  faDownload,
+  faTrash,
+} from '@fortawesome/free-solid-svg-icons'
+import { useMutation, useQueryCache } from '@pinia/colada'
+import { useI18n } from 'vue-i18n'
+import { computed, ref } from 'vue'
+import { useRoute } from 'vue-router'
+import {
+  BACKUP_MAX_SIZE_PER_SYSTEM,
+  BACKUP_MAX_SLOTS_PER_SYSTEM,
+  deleteBackup,
+  formatBackupSize,
+  getBackupDownloadUrl,
+  SYSTEM_BACKUPS_KEY,
+  type BackupMetadata,
+} from '@/lib/backups'
+import { formatDateTimeNoSeconds } from '@/lib/dateTime'
+import { useSystemBackups } from '@/queries/systems/backups'
+import { useNotificationsStore } from '@/stores/notifications'
+import DeleteObjectModal from '@/components/DeleteObjectModal.vue'
+import UpdatingSpinner from '@/components/UpdatingSpinner.vue'
+
+const { t, locale } = useI18n()
+const route = useRoute()
+const notificationsStore = useNotificationsStore()
+const queryCache = useQueryCache()
+
+const { state, asyncStatus } = useSystemBackups()
+
+function refresh() {
+  queryCache.invalidateQueries({ key: [SYSTEM_BACKUPS_KEY] })
+}
+
+const maxSlotsLabel = BACKUP_MAX_SLOTS_PER_SYSTEM
+const maxSizeLabel = formatBackupSize(BACKUP_MAX_SIZE_PER_SYSTEM)
+
+const slotsUsed = computed(() => state.value.data?.slots_used ?? 0)
+const quotaUsedBytes = computed(() => state.value.data?.quota_used_bytes ?? 0)
+
+// Download flow: ask backend for a short-lived presigned URL and follow
+// it from the browser. We do not inline the redirect because the
+// presigned URL is cross-origin and we want the download to use the
+// browser's native networking (preserves progress UI, handles retries).
+const downloadingId = ref<string | null>(null)
+
+async function download(backup: BackupMetadata) {
+  downloadingId.value = backup.id
+  try {
+    const { download_url } = await getBackupDownloadUrl(
+      route.params.systemId as string,
+      backup.id,
+    )
+    // Use a hidden anchor to preserve the filename the server exposes
+    // and avoid replacing the current tab.
+    const anchor = document.createElement('a')
+    anchor.href = download_url
+    anchor.download = backup.filename || backup.id
+    document.body.appendChild(anchor)
+    anchor.click()
+    document.body.removeChild(anchor)
+  } catch (err) {
+    notificationsStore.createNotification({
+      kind: 'error',
+      title: t('backups.cannot_download_backup'),
+      description: err instanceof Error ? err.message : String(err),
+    })
+  } finally {
+    downloadingId.value = null
+  }
+}
+
+// Delete flow with confirmation modal — we require an explicit confirm
+// because deletes are irreversible (the S3 object is gone, and the
+// encryption key lives on the appliance, so we cannot rebuild it).
+const deleteTarget = ref<BackupMetadata | null>(null)
+const deleteVisible = ref(false)
+
+function askDelete(backup: BackupMetadata) {
+  deleteTarget.value = backup
+  deleteVisible.value = true
+}
+
+function closeDelete() {
+  deleteVisible.value = false
+}
+
+const deleteTargetDate = computed(() =>
+  deleteTarget.value
+    ? formatDateTimeNoSeconds(new Date(deleteTarget.value.uploaded_at), locale.value)
+    : '',
+)
+
+const deleteTargetFilename = computed(
+  () => deleteTarget.value?.filename || deleteTarget.value?.id || '',
+)
+
+const {
+  mutate: deleteBackupMutate,
+  isLoading: deleteBackupLoading,
+  error: deleteBackupError,
+  reset: deleteBackupReset,
+} = useMutation({
+  mutation: (backup: BackupMetadata) =>
+    deleteBackup(route.params.systemId as string, backup.id),
+  onSuccess(_data, backup) {
+    setTimeout(() => {
+      notificationsStore.createNotification({
+        kind: 'success',
+        title: t('backups.backup_deleted'),
+        description: t('backups.backup_deleted_description', {
+          filename: backup.filename || backup.id,
+        }),
+      })
+    }, 500)
+    deleteVisible.value = false
+    deleteTarget.value = null
+  },
+  onSettled() {
+    queryCache.invalidateQueries({ key: [SYSTEM_BACKUPS_KEY] })
+  },
+})
+
+function onDeleteShow() {
+  deleteBackupReset()
+}
+
+function getKebabMenuItems(backup: BackupMetadata): NeDropdownItem[] {
+  return [
+    {
+      id: 'download',
+      label: t('backups.download'),
+      icon: faDownload,
+      disabled: downloadingId.value === backup.id,
+      action: () => download(backup),
+    },
+    {
+      id: 'delete',
+      label: t('backups.delete'),
+      icon: faTrash,
+      danger: true,
+      action: () => askDelete(backup),
+    },
+  ]
+}
+</script>
+
+<template>
+  <NeCard>
+    <div class="mb-6 flex flex-col items-start justify-between gap-4 xl:flex-row">
+      <div>
+        <NeHeading tag="h6" class="mb-2">{{ $t('backups.title') }}</NeHeading>
+        <div class="max-w-2xl text-gray-500 dark:text-gray-400">
+          {{ $t('backups.page_description') }}
+        </div>
+      </div>
+      <div class="flex items-center gap-2">
+        <UpdatingSpinner v-if="asyncStatus === 'loading' && state.status !== 'pending'" />
+        <NeButton
+          kind="tertiary"
+          size="sm"
+          :disabled="asyncStatus === 'loading'"
+          @click="refresh()"
+        >
+          <template #prefix>
+            <FontAwesomeIcon :icon="faArrowsRotate" aria-hidden="true" />
+          </template>
+          {{ $t('backups.refresh') }}
+        </NeButton>
+      </div>
+    </div>
+
+    <!-- retention / usage summary -->
+    <div
+      v-if="state.status === 'success' && state.data"
+      class="mb-6 grid grid-cols-1 gap-3 sm:grid-cols-3"
+    >
+      <div class="rounded-md border border-gray-200 p-3 dark:border-gray-700">
+        <div class="text-xs text-gray-500 dark:text-gray-400">
+          {{ $t('backups.slots_usage') }}
+        </div>
+        <div class="mt-1 text-lg font-medium">
+          {{
+            $t('backups.slots_used_of_max', {
+              used: slotsUsed,
+              max: maxSlotsLabel,
+            })
+          }}
+        </div>
+      </div>
+      <div class="rounded-md border border-gray-200 p-3 dark:border-gray-700">
+        <div class="text-xs text-gray-500 dark:text-gray-400">
+          {{ $t('backups.storage_usage') }}
+        </div>
+        <div class="mt-1 text-lg font-medium">
+          {{ formatBackupSize(quotaUsedBytes) }} / {{ maxSizeLabel }}
+        </div>
+      </div>
+      <div class="rounded-md border border-gray-200 p-3 dark:border-gray-700 sm:col-span-1">
+        <div class="text-xs text-gray-500 dark:text-gray-400">
+          {{ $t('backups.retention_policy') }}
+        </div>
+        <div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
+          {{
+            $t('backups.retention_policy_description', {
+              slots: maxSlotsLabel,
+              size: maxSizeLabel,
+            })
+          }}
+        </div>
+      </div>
+    </div>
+
+    <!-- error -->
+    <NeInlineNotification
+      v-if="state.status === 'error'"
+      kind="error"
+      :title="$t('backups.cannot_retrieve_backups')"
+      :description="state.error?.message"
+      class="mb-4"
+    />
+
+    <!-- loading skeleton -->
+    <NeSkeleton v-else-if="state.status === 'pending'" :lines="8" />
+
+    <!-- empty -->
+    <NeEmptyState
+      v-else-if="!state.data?.backups?.length"
+      :title="$t('backups.no_backups')"
+      :description="$t('backups.no_backups_description')"
+      :icon="faBoxArchive"
+    />
+
+    <!-- table -->
+    <template v-else>
+      <NeTable :aria-label="$t('backups.title')" card-breakpoint="md">
+        <NeTableHead>
+          <NeTableHeadCell>{{ $t('backups.uploaded_at') }}</NeTableHeadCell>
+          <NeTableHeadCell>{{ $t('backups.filename') }}</NeTableHeadCell>
+          <NeTableHeadCell>{{ $t('backups.size') }}</NeTableHeadCell>
+          <NeTableHeadCell>{{ $t('backups.sha256') }}</NeTableHeadCell>
+          <NeTableHeadCell>
+            <!-- no header for actions -->
+          </NeTableHeadCell>
+        </NeTableHead>
+        <NeTableBody>
+          <NeTableRow v-for="backup in state.data.backups" :key="backup.id">
+            <NeTableCell :data-label="$t('backups.uploaded_at')">
+              {{ formatDateTimeNoSeconds(new Date(backup.uploaded_at), locale) }}
+            </NeTableCell>
+            <NeTableCell :data-label="$t('backups.filename')">
+              <div class="flex items-center gap-2">
+                <FontAwesomeIcon
+                  :icon="faBoxArchive"
+                  class="h-4 w-4 shrink-0 text-gray-400"
+                  aria-hidden="true"
+                />
+                <span class="font-medium break-all">{{ backup.filename || backup.id }}</span>
+              </div>
+            </NeTableCell>
+            <NeTableCell :data-label="$t('backups.size')">
+              {{ formatBackupSize(backup.size) }}
+            </NeTableCell>
+            <NeTableCell :data-label="$t('backups.sha256')">
+              <NeTooltip v-if="backup.sha256" placement="top">
+                <template #trigger>
+                  <code class="text-xs">{{ backup.sha256.slice(0, 12) }}…</code>
+                </template>
+                <template #content>
+                  <code class="text-xs break-all">{{ backup.sha256 }}</code>
+                </template>
+              </NeTooltip>
+              <span v-else>-</span>
+            </NeTableCell>
+            <NeTableCell :data-label="$t('backups.actions')">
+              <div class="flex justify-end">
+                <NeDropdown :items="getKebabMenuItems(backup)" :align-to-right="true" />
+              </div>
+            </NeTableCell>
+          </NeTableRow>
+        </NeTableBody>
+      </NeTable>
+    </template>
+
+    <DeleteObjectModal
+      :visible="deleteVisible"
+      :title="$t('backups.delete_backup')"
+      :primary-label="$t('backups.delete')"
+      :deleting="deleteBackupLoading"
+      :confirmation-message="
+        $t('backups.delete_backup_confirmation', {
+          filename: deleteTargetFilename,
+          date: deleteTargetDate,
+        })
+      "
+      :error-title="$t('backups.cannot_delete_backup')"
+      :error-description="deleteBackupError?.message"
+      @show="onDeleteShow"
+      @close="closeDelete"
+      @primary-click="deleteTarget && deleteBackupMutate(deleteTarget)"
+    />
+  </NeCard>
+</template>
diff --git a/frontend/src/components/systems/SystemInfoCard.vue b/frontend/src/components/systems/SystemInfoCard.vue
index 0a230b850..94b3f1ed1 100644
--- a/frontend/src/components/systems/SystemInfoCard.vue
+++ b/frontend/src/components/systems/SystemInfoCard.vue
@@ -192,7 +192,7 @@ function getKebabMenuItems() {
     <NeSkeleton v-else-if="systemDetail.status === 'pending'" :lines="10" />
     <div v-else-if="systemDetail.data">
       <!-- product logo and name -->
-      <div class="mb-4 flex items-center justify-between gap-4">
+      <div class="mb-4 flex h-10 items-center justify-between gap-4">
         <div class="flex items-center gap-4">
           <SystemLogo :system="systemDetail.data.type" />
           <NeHeading tag="h6">
diff --git a/frontend/src/components/systems/SystemStatusCard.vue b/frontend/src/components/systems/SystemStatusCard.vue
index 8d344f02f..5a1e44121 100644
--- a/frontend/src/components/systems/SystemStatusCard.vue
+++ b/frontend/src/components/systems/SystemStatusCard.vue
@@ -13,10 +13,16 @@ import {
 } from '@nethesis/vue-components'
 import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
 import { useLatestInventory } from '@/queries/systems/latestInventory'
-import { faCircleInfo, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
+import {
+  faCircleCheck,
+  faCircleInfo,
+  faTriangleExclamation,
+} from '@fortawesome/free-solid-svg-icons'
 import { formatDateTimeNoSeconds, formatTimeAgo, formatUptime } from '@/lib/dateTime'
 import { useI18n } from 'vue-i18n'
 import { useSystemDetail } from '@/queries/systems/systemDetail'
+import { useSystemActiveAlerts } from '@/queries/systems/activeAlerts'
+import { useSystemBackups } from '@/queries/systems/backups'
 import DataItem from '../DataItem.vue'
 import { computed } from 'vue'
 import SystemStatusIcon from './SystemStatusIcon.vue'
@@ -26,6 +32,13 @@ import type { NsecFacts } from '@/lib/systems/nsecFacts'
 const { t, locale } = useI18n()
 const { state: systemDetail } = useSystemDetail()
 const { state: latestInventory } = useLatestInventory()
+const { state: activeAlerts } = useSystemActiveAlerts()
+const { state: systemBackups } = useSystemBackups()
+
+const activeAlertsCount = computed(() => activeAlerts.value.data?.length ?? 0)
+const backupsCount = computed(() => systemBackups.value.data?.backups?.length ?? 0)
+const hasActiveAlerts = computed(() => activeAlertsCount.value > 0)
+const hasBackups = computed(() => backupsCount.value > 0)
 
 const uptimeLabel = computed(() => {
   return systemType.value === 'ns8'
@@ -76,7 +89,7 @@ const timezone = computed(() => {
 
 <template>
   <NeCard>
-    <div class="mb-4 flex items-center gap-4">
+    <div class="mb-4 flex h-10 items-center gap-4">
       <FontAwesomeIcon :icon="faCircleInfo" class="size-5 shrink-0" aria-hidden="true" />
       <NeHeading tag="h6">
         {{ $t('common.status').toUpperCase() }}
@@ -177,6 +190,70 @@ const timezone = computed(() => {
           {{ timezone ? timezone : '-' }}
         </template>
       </DataItem>
+      <!-- alerts indicator -->
+      <DataItem>
+        <template #label>
+          {{ $t('alerting.title') }}
+        </template>
+        <template #data>
+          <div class="flex items-center gap-2">
+            <template v-if="activeAlerts.status === 'pending'">
+              <span>-</span>
+            </template>
+            <template v-else-if="activeAlerts.status === 'error'">
+              <span>-</span>
+            </template>
+            <template v-else-if="hasActiveAlerts">
+              <FontAwesomeIcon
+                :icon="faTriangleExclamation"
+                class="size-4 text-amber-700 dark:text-amber-500"
+                aria-hidden="true"
+              />
+              {{ $t('system_detail.n_active_alerts', { n: activeAlertsCount }, activeAlertsCount) }}
+            </template>
+            <template v-else>
+              <FontAwesomeIcon
+                :icon="faCircleCheck"
+                class="size-4 text-green-700 dark:text-green-500"
+                aria-hidden="true"
+              />
+              {{ $t('system_detail.no_active_alerts') }}
+            </template>
+          </div>
+        </template>
+      </DataItem>
+      <!-- backups indicator -->
+      <DataItem>
+        <template #label>
+          {{ $t('backups.title') }}
+        </template>
+        <template #data>
+          <div class="flex items-center gap-2">
+            <template v-if="systemBackups.status === 'pending'">
+              <span>-</span>
+            </template>
+            <template v-else-if="systemBackups.status === 'error'">
+              <span>-</span>
+            </template>
+            <template v-else-if="hasBackups">
+              <FontAwesomeIcon
+                :icon="faCircleCheck"
+                class="size-4 text-green-700 dark:text-green-500"
+                aria-hidden="true"
+              />
+              {{ $t('system_detail.n_backups_stored', { n: backupsCount }, backupsCount) }}
+            </template>
+            <template v-else>
+              <FontAwesomeIcon
+                :icon="faTriangleExclamation"
+                class="size-4 text-amber-700 dark:text-amber-500"
+                aria-hidden="true"
+              />
+              {{ $t('system_detail.no_backups_stored') }}
+            </template>
+          </div>
+        </template>
+      </DataItem>
     </div>
   </NeCard>
 </template>
diff --git a/frontend/src/components/systems/SystemSubscriptionCard.vue b/frontend/src/components/systems/SystemSubscriptionCard.vue
index ef444f001..197d8a841 100644
--- a/frontend/src/components/systems/SystemSubscriptionCard.vue
+++ b/frontend/src/components/systems/SystemSubscriptionCard.vue
@@ -54,7 +54,7 @@ function onCloseSecretRegeneratedModal() {
 
 <template>
   <NeCard>
-    <div class="mb-4 flex items-center justify-between gap-4">
+    <div class="mb-4 flex h-10 items-center justify-between gap-4">
       <div class="flex items-center gap-4">
         <FontAwesomeIcon :icon="faAward" class="size-5 shrink-0" aria-hidden="true" />
         <NeHeading tag="h6">
diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json
index d40cb31e7..6e94058d0 100644
--- a/frontend/src/i18n/en/translation.json
+++ b/frontend/src/i18n/en/translation.json
@@ -537,6 +537,10 @@
     "cannot_retrieve_latest_inventory": "Cannot retrieve latest inventory",
     "no_inventory_available": "No inventory available",
     "no_inventory_available_description": "The system has not sent any inventory data yet.",
+    "no_active_alerts": "No active alerts",
+    "n_active_alerts": "{n} active alert | {n} active alerts",
+    "no_backups_stored": "No backup uploaded yet",
+    "n_backups_stored": "{n} backup stored | {n} backups stored",
     "total_changes": "Total changes",
     "critical_changes": "Critical",
     "high_changes": "High",
@@ -693,6 +697,32 @@
     "type_to_confirm": "Type '{confirmationText}' to confirm",
     "confirmation_text_does_not_match": "Enter '{confirmationText}' to confirm"
   },
+  "backups": {
+    "title": "Backups",
+    "page_description": "Configuration snapshots uploaded by this system. Each file is end-to-end encrypted on the appliance — only the system that created it can decrypt its contents.",
+    "uploaded_at": "Uploaded at",
+    "filename": "Filename",
+    "size": "Size",
+    "sha256": "SHA-256",
+    "actions": "Actions",
+    "download": "Download",
+    "delete": "Delete",
+    "no_backups": "No backups yet",
+    "no_backups_description": "This system has not uploaded any configuration backup. Backups are created automatically by the appliance on a daily schedule.",
+    "cannot_retrieve_backups": "Cannot retrieve backups",
+    "retention_policy": "Retention policy",
+    "retention_policy_description": "The ingest side keeps at most {slots} backups per system, up to {size} of total storage. Beyond any of those caps the oldest entry is pruned automatically at upload time.",
+    "storage_usage": "Storage used",
+    "slots_usage": "Backups kept",
+    "slots_used_of_max": "{used} of {max}",
+    "delete_backup": "Delete backup",
+    "delete_backup_confirmation": "Are you sure you want to delete {filename}, uploaded on {date}? This action cannot be undone.",
+    "refresh": "Refresh",
+    "cannot_delete_backup": "Cannot delete backup",
+    "backup_deleted": "Backup deleted",
+    "backup_deleted_description": "{filename} has been removed.",
+    "cannot_download_backup": "Cannot download backup"
+  },
   "alerting": {
     "title": "Alerts",
     "config_title": "Alerting Configuration",
diff --git a/frontend/src/i18n/it/translation.json b/frontend/src/i18n/it/translation.json
index 4557805dd..abe4f50ee 100644
--- a/frontend/src/i18n/it/translation.json
+++ b/frontend/src/i18n/it/translation.json
@@ -515,6 +515,10 @@
     "cannot_retrieve_latest_inventory": "Impossibile recuperare l'ultimo inventario",
     "no_inventory_available": "Nessun inventario disponibile",
     "no_inventory_available_description": "Il sistema non ha ancora inviato dati di inventario.",
+    "no_active_alerts": "Nessun avviso attivo",
+    "n_active_alerts": "{n} avviso attivo | {n} avvisi attivi",
+    "no_backups_stored": "Nessun backup caricato",
+    "n_backups_stored": "{n} backup conservato | {n} backup conservati",
     "subscription": "Subscription",
     "active_since": "Attiva dal",
     "system_key": "Chiave di sistema",
@@ -615,6 +619,32 @@
     "type_to_confirm": "Digita '{confirmationText}' per confermare",
     "confirmation_text_does_not_match": "Inserisci '{confirmationText}' per confermare"
   },
+  "backups": {
+    "title": "Backup",
+    "page_description": "Snapshot di configurazione caricati da questo sistema. Ogni file è cifrato end-to-end sull'appliance — solo il sistema che l'ha creato può decifrarne il contenuto.",
+    "uploaded_at": "Caricato il",
+    "filename": "Nome file",
+    "size": "Dimensione",
+    "sha256": "SHA-256",
+    "actions": "Azioni",
+    "download": "Scarica",
+    "delete": "Elimina",
+    "no_backups": "Nessun backup",
+    "no_backups_description": "Questo sistema non ha ancora caricato alcun backup di configurazione. I backup vengono creati automaticamente dall'appliance con cadenza giornaliera.",
+    "cannot_retrieve_backups": "Impossibile recuperare i backup",
+    "retention_policy": "Politica di retention",
+    "retention_policy_description": "Il servizio di ingest mantiene al massimo {slots} backup per sistema, fino a {size} di spazio totale. Superato uno dei due limiti, il backup più vecchio viene eliminato automaticamente al momento dell'upload.",
+    "storage_usage": "Spazio utilizzato",
+    "slots_usage": "Backup conservati",
+    "slots_used_of_max": "{used} di {max}",
+    "delete_backup": "Elimina backup",
+    "delete_backup_confirmation": "Vuoi eliminare {filename}, caricato il {date}? L'operazione non è reversibile.",
+    "refresh": "Aggiorna",
+    "cannot_delete_backup": "Impossibile eliminare il backup",
+    "backup_deleted": "Backup eliminato",
+    "backup_deleted_description": "{filename} è stato rimosso.",
+    "cannot_download_backup": "Impossibile scaricare il backup"
+  },
   "alerting": {
     "title": "Avvisi",
     "config_title": "Configurazione avvisi",
diff --git a/frontend/src/lib/backups.test.ts b/frontend/src/lib/backups.test.ts
new file mode 100644
index 000000000..7159e916a
--- /dev/null
+++ b/frontend/src/lib/backups.test.ts
@@ -0,0 +1,47 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import { describe, expect, it } from 'vitest'
+import { formatBackupSize } from './backups'
+
+describe('formatBackupSize', () => {
+  it('returns bytes as-is below 1 KiB', () => {
+    expect(formatBackupSize(0)).toBe('0 B')
+    expect(formatBackupSize(1)).toBe('1 B')
+    expect(formatBackupSize(512)).toBe('512 B')
+    expect(formatBackupSize(1023)).toBe('1023 B')
+  })
+
+  it('switches to KB at 1024 bytes', () => {
+    expect(formatBackupSize(1024)).toBe('1.00 KB')
+    expect(formatBackupSize(1536)).toBe('1.50 KB')
+  })
+
+  it('formats MB, GB and TB tiers', () => {
+    expect(formatBackupSize(1024 * 1024)).toBe('1.00 MB')
+    expect(formatBackupSize(500 * 1024 * 1024)).toBe('500 MB')
+    expect(formatBackupSize(1024 * 1024 * 1024)).toBe('1.00 GB')
+    expect(formatBackupSize(1024 * 1024 * 1024 * 1024)).toBe('1.00 TB')
+  })
+
+  it('picks decimal precision based on magnitude', () => {
+    // < 10 → 2 decimals
+    expect(formatBackupSize(1.23 * 1024 * 1024)).toBe('1.23 MB')
+    // 10..99 → 1 decimal
+    expect(formatBackupSize(12.34 * 1024 * 1024)).toBe('12.3 MB')
+    // >= 100 → no decimals
+    expect(formatBackupSize(123.45 * 1024 * 1024)).toBe('123 MB')
+  })
+
+  it('caps the unit at TB for very large values', () => {
+    const tenPetabytes = 10 * 1024 * 1024 * 1024 * 1024 * 1024
+    expect(formatBackupSize(tenPetabytes)).toMatch(/TB$/)
+  })
+
+  it('returns "-" for invalid inputs', () => {
+    expect(formatBackupSize(NaN)).toBe('-')
+    expect(formatBackupSize(-1)).toBe('-')
+    expect(formatBackupSize(-0.5)).toBe('-')
+    expect(formatBackupSize(Infinity)).toBe('-')
+  })
+})
diff --git a/frontend/src/lib/backups.ts b/frontend/src/lib/backups.ts
new file mode 100644
index 000000000..4a477fa2d
--- /dev/null
+++ b/frontend/src/lib/backups.ts
@@ -0,0 +1,101 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import axios from 'axios'
+import { API_URL } from './config'
+import { useLoginStore } from '@/stores/login'
+
+export const SYSTEM_BACKUPS_KEY = 'systemBackups'
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export interface BackupMetadata {
+  id: string
+  filename: string
+  size: number
+  sha256: string
+  mimetype: string
+  uploaded_at: string
+}
+
+export interface BackupListData {
+  backups: BackupMetadata[]
+  quota_used_bytes: number
+  slots_used: number
+}
+
+export interface BackupListResponse {
+  code: number
+  message: string
+  data: BackupListData
+}
+
+export interface BackupDownloadData {
+  download_url: string
+  expires_in_seconds: number
+}
+
+export interface BackupDownloadResponse {
+  code: number
+  message: string
+  data: BackupDownloadData
+}
+
+// ── Retention policy (mirrors collect defaults; kept in sync with
+//    BACKUP_MAX_PER_SYSTEM and BACKUP_MAX_SIZE_PER_SYSTEM). The backend
+//    does not currently surface these values so we render them from
+//    constants and update them here if the server-side defaults shift. ──
+
+export const BACKUP_MAX_SLOTS_PER_SYSTEM = 10
+export const BACKUP_MAX_SIZE_PER_SYSTEM = 500 * 1024 * 1024
+
+// ── API ───────────────────────────────────────────────────────────────────────
+
+export const getSystemBackups = (systemId: string) => {
+  const loginStore = useLoginStore()
+
+  return axios
+    .get<BackupListResponse>(`${API_URL}/systems/${systemId}/backups`, {
+      headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+    })
+    .then((res) => res.data.data)
+}
+
+export const getBackupDownloadUrl = (systemId: string, backupId: string) => {
+  const loginStore = useLoginStore()
+
+  return axios
+    .get<BackupDownloadResponse>(
+      `${API_URL}/systems/${systemId}/backups/${encodeURIComponent(backupId)}/download`,
+      {
+        headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+      },
+    )
+    .then((res) => res.data.data)
+}
+
+export const deleteBackup = (systemId: string, backupId: string) => {
+  const loginStore = useLoginStore()
+
+  return axios.delete(
+    `${API_URL}/systems/${systemId}/backups/${encodeURIComponent(backupId)}`,
+    {
+      headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+    },
+  )
+}
+
+// ── Formatting helpers ────────────────────────────────────────────────────────
+
+export function formatBackupSize(bytes: number): string {
+  if (!Number.isFinite(bytes) || bytes < 0) return '-'
+  if (bytes < 1024) return `${bytes} B`
+  const units = ['KB', 'MB', 'GB', 'TB']
+  let value = bytes / 1024
+  let unit = 0
+  while (value >= 1024 && unit < units.length - 1) {
+    value /= 1024
+    unit++
+  }
+  return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unit]}`
+}
diff --git a/frontend/src/queries/systems/activeAlerts.ts b/frontend/src/queries/systems/activeAlerts.ts
new file mode 100644
index 000000000..bc1ab3681
--- /dev/null
+++ b/frontend/src/queries/systems/activeAlerts.ts
@@ -0,0 +1,26 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import { getSystemActiveAlerts } from '@/lib/alerting'
+import { useLoginStore } from '@/stores/login'
+import { defineQuery, useQuery } from '@pinia/colada'
+import { useRoute } from 'vue-router'
+
+export const SYSTEM_ACTIVE_ALERTS_KEY = 'systemActiveAlerts'
+
+export const useSystemActiveAlerts = defineQuery(() => {
+  const loginStore = useLoginStore()
+  const route = useRoute()
+
+  const { state, asyncStatus, ...rest } = useQuery({
+    key: () => [SYSTEM_ACTIVE_ALERTS_KEY, route.params.systemId],
+    enabled: () => !!loginStore.jwtToken && !!route.params.systemId,
+    query: () => getSystemActiveAlerts(route.params.systemId as string),
+  })
+
+  return {
+    ...rest,
+    state,
+    asyncStatus,
+  }
+})
diff --git a/frontend/src/queries/systems/backups.ts b/frontend/src/queries/systems/backups.ts
new file mode 100644
index 000000000..d425837b6
--- /dev/null
+++ b/frontend/src/queries/systems/backups.ts
@@ -0,0 +1,24 @@
+//  Copyright (C) 2026 Nethesis S.r.l.
+//  SPDX-License-Identifier: GPL-3.0-or-later
+
+import { getSystemBackups, SYSTEM_BACKUPS_KEY } from '@/lib/backups'
+import { useLoginStore } from '@/stores/login'
+import { defineQuery, useQuery } from '@pinia/colada'
+import { useRoute } from 'vue-router'
+
+export const useSystemBackups = defineQuery(() => {
+  const loginStore = useLoginStore()
+  const route = useRoute()
+
+  const { state, asyncStatus, ...rest } = useQuery({
+    key: () => [SYSTEM_BACKUPS_KEY, route.params.systemId],
+    enabled: () => !!loginStore.jwtToken && !!route.params.systemId,
+    query: () => getSystemBackups(route.params.systemId as string),
+  })
+
+  return {
+    ...rest,
+    state,
+    asyncStatus,
+  }
+})
diff --git a/frontend/src/views/SystemDetailView.vue b/frontend/src/views/SystemDetailView.vue
index d1bcb0d27..cee1fed5d 100644
--- a/frontend/src/views/SystemDetailView.vue
+++ b/frontend/src/views/SystemDetailView.vue
@@ -21,6 +21,7 @@ import { useI18n } from 'vue-i18n'
 import SystemOverviewPanel from '@/components/systems/SystemOverviewPanel.vue'
 import SystemChangeHistoryPanel from '@/components/systems/SystemChangeHistoryPanel.vue'
 import SystemAlertHistoryPanel from '@/components/systems/SystemAlertHistoryPanel.vue'
+import SystemBackupsPanel from '@/components/systems/SystemBackupsPanel.vue'
 import { useLatestInventory } from '@/queries/systems/latestInventory'
 import { useSystemReachability } from '@/queries/systems/systemReachability'
 import { computed } from 'vue'
@@ -33,6 +34,7 @@ const { tabs, selectedTab } = useTabs([
   { name: 'overview', label: t('system_detail.overview') },
   { name: 'change_history', label: t('system_detail.change_history') },
   { name: 'alert_history', label: t('alerting.title') },
+  { name: 'backups', label: t('backups.title') },
 ])
 
 const isSystemReachable = computed(() => !!reachabilityState.value.data?.reachable)
@@ -130,5 +132,6 @@ const openSystem = () => {
     <SystemOverviewPanel v-if="selectedTab === 'overview'" />
     <SystemChangeHistoryPanel v-else-if="selectedTab === 'change_history'" />
     <SystemAlertHistoryPanel v-else-if="selectedTab === 'alert_history'" />
+    <SystemBackupsPanel v-else-if="selectedTab === 'backups'" />
   </div>
 </template>
diff --git a/proxy/.render-build-trigger b/proxy/.render-build-trigger
index 4ee864ad3..320733ced 100644
--- a/proxy/.render-build-trigger
+++ b/proxy/.render-build-trigger
@@ -2,9 +2,9 @@
 # This file is used to force Docker service rebuilds in PR previews
 # Modify LAST_UPDATE to trigger rebuilds
 
-LAST_UPDATE=2026-04-07T13:40:58Z
+LAST_UPDATE=2026-04-22T07:15:29Z
 
 # Instructions:
 # 1. To force rebuild of Docker services in a PR, update LAST_UPDATE
-# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-04-07T13:40:58Z/LAST_UPDATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)/" $f/.render-build-trigger
+# 2. Run: perl -i -pe "s/LAST_UPDATE=2026-04-22T07:15:29Z
 # 2. Commit and push changes to trigger Docker rebuilds
diff --git a/proxy/nginx.conf b/proxy/nginx.conf
index a28b5f02a..afd78237c 100644
--- a/proxy/nginx.conf
+++ b/proxy/nginx.conf
@@ -62,6 +62,32 @@ http {
             proxy_read_timeout 30s;
         }
 
+        # Backup upload / download on collect. POST /api/systems/backups
+        # receives a GPG blob up to 2 GB from appliances on slow uplinks;
+        # GET /api/systems/backups/:id streams the blob back out for
+        # restore. The defaults from the parent /collect/api/ (1 MB body
+        # cap, 30 s read timeout) would 413 and 504 both flows, so we
+        # match this sub-path first (^~) and override the relevant
+        # directives. request_buffering off keeps nginx's memory
+        # footprint flat while the body streams through to collect.
+        location ^~ /collect/api/systems/backups {
+            set $collect_upstream http://${COLLECT_SERVICE_NAME}:10000;
+            rewrite ^/collect/api/(.*)$ /api/$1 break;
+            proxy_pass $collect_upstream;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto $scheme;
+            proxy_set_header X-Forwarded-Host $host;
+
+            client_max_body_size 2g;
+            client_body_timeout 600s;
+            proxy_request_buffering off;
+            proxy_connect_timeout 30s;
+            proxy_send_timeout 600s;
+            proxy_read_timeout 600s;
+        }
+
         # Backend service routes - redirect without trailing slash
         location = /backend/api {
             return 301 /backend/api/;
diff --git a/render.yaml b/render.yaml
index 3d2ae7bad..e8ab11663 100644
--- a/render.yaml
+++ b/render.yaml
@@ -124,6 +124,16 @@ services:
       - key: ALERTING_HISTORY_WEBHOOK_SECRET
         sync: false
 
+      # Backup storage (same DO Spaces account as Mimir; bucket is distinct)
+      - key: BACKUP_S3_ENDPOINT
+        sync: false
+      - key: BACKUP_S3_ACCESS_KEY
+        sync: false
+      - key: BACKUP_S3_SECRET_KEY
+        sync: false
+      - key: BACKUP_S3_BUCKET
+        sync: false
+
   # Production Collect Service (Private Service)
   - type: web
     name: my-collect-prod
@@ -165,6 +175,16 @@ services:
       - key: ALERTING_HISTORY_WEBHOOK_SECRET
         sync: false
 
+      # Backup storage (same DO Spaces account as Mimir; bucket is distinct)
+      - key: BACKUP_S3_ENDPOINT
+        sync: false
+      - key: BACKUP_S3_ACCESS_KEY
+        sync: false
+      - key: BACKUP_S3_SECRET_KEY
+        sync: false
+      - key: BACKUP_S3_BUCKET
+        sync: false
+
   # Production Frontend (Private Service)
   - type: web
     name: my-frontend-prod
@@ -312,6 +332,16 @@ services:
       - key: ALERTING_HISTORY_WEBHOOK_SECRET
         sync: false
 
+      # Backup storage (same DO Spaces account as Mimir; bucket is distinct)
+      - key: BACKUP_S3_ENDPOINT
+        sync: false
+      - key: BACKUP_S3_ACCESS_KEY
+        sync: false
+      - key: BACKUP_S3_SECRET_KEY
+        sync: false
+      - key: BACKUP_S3_BUCKET
+        sync: false
+
     autoDeploy: true   # Auto-deploy on every commit
     branch: main
     pullRequestPreviewsEnabled: true  # PR previews enabled
@@ -357,6 +387,16 @@ services:
       - key: ALERTING_HISTORY_WEBHOOK_SECRET
         sync: false
 
+      # Backup storage (same DO Spaces account as Mimir; bucket is distinct)
+      - key: BACKUP_S3_ENDPOINT
+        sync: false
+      - key: BACKUP_S3_ACCESS_KEY
+        sync: false
+      - key: BACKUP_S3_SECRET_KEY
+        sync: false
+      - key: BACKUP_S3_BUCKET
+        sync: false
+
     autoDeploy: true   # Auto-deploy on every commit
     branch: main
     pullRequestPreviewsEnabled: true  # PR previews enabled
diff --git a/services/mimir/.render-build-trigger b/services/mimir/.render-build-trigger
index 4545cd156..f6a0c597a 100644
--- a/services/mimir/.render-build-trigger
+++ b/services/mimir/.render-build-trigger
@@ -2,7 +2,7 @@
 # This file is used to force Docker service rebuilds in PR previews
 # Modify LAST_UPDATE to trigger rebuilds
 
-LAST_UPDATE=2026-04-07T13:40:58Z
+LAST_UPDATE=2026-04-22T07:15:29Z
 
 # Instructions:
 # 1. To force rebuild of Docker services in a PR, update LAST_UPDATE
