Skip to content

Tenant Backups

Every SaaS tenant gets the same cloud-backup feature that ships with standalone ProxPanel: scheduled and on-demand backups of their schema, encrypted with a tenant-specific key, stored in a per-tenant namespace on the license server’s backup store. A SaaS tenant cannot see, list, download, or decrypt any other tenant’s backups — the isolation is enforced both at the API and at the storage layer.

This page documents how the SaaS-specific bits work. For the in-tenant Backups UI itself, see Backups — it’s identical to the standalone experience.

A tenant backup is a full pg_dump --schema=tenant_<slug> of that tenant’s schema, plus a snapshot of /uploads/tenant_<id>/ (logo, login background, favicon, generated PDFs). The backup is bundled into a single .proisp.bak file using the V2 encrypted backup format documented in Backups.

A tenant backup does not include:

  • The public schema (tenant registry, super-admin audit log, SaaS billing) — those are super-admin concerns and backed up separately.
  • Other tenants’ schemas — even on the same Postgres instance.
  • The host’s docker-compose.yml, nginx config, or /etc/letsencrypt/ — host configuration is the operator’s responsibility.

Tenant backups are uploaded to the license server’s cloud storage at:

cloud_backups/tenant_<id>/<filename>.proisp.bak

The tenant_<id> path component scopes the namespace. The license server’s backup API enforces this: a tenant authenticated by its LICENSE_KEY + X-Hardware-Id can only list, download, and delete objects under its own prefix.

The database side mirrors this — the cloud_backups table on the license server has a tenant_id column. Every backup row carries the tenant ID; every API call filters by it. There is no cross-tenant query path.

Tenant backups use the same V2 encryption format as standalone backups (see the Password Encryption page for the cipher choice). What differs:

ComponentStandaloneSaaS
Encryption key derivationsha256(license_key + db_password)sha256(license_key + tenant_id + tenant_db_password)
Backup headerPROXPANEL_ENCRYPTED_BACKUP_V2\nLICENSE_KEY=...PROXPANEL_ENCRYPTED_BACKUP_V2\nLICENSE_KEY=...\nTENANT_ID=...
Decryption requiresThe license key + the cluster’s DB passwordThe license key + the tenant ID + the cluster’s DB password (super-admin) OR the tenant’s own per-tenant key (in-tenant restore)

The result: even if an attacker exfiltrates one tenant’s backup file plus the cluster’s license key, they cannot decrypt another tenant’s backup of the same cluster. The tenant ID is part of the key derivation.

Each tenant has a quota and a retention policy, defined by their plan:

PlanFree storageMax retentionBeyond-quota behaviour
trial100 MB7 daysNew backups rejected with 507 Insufficient Storage
starter1 GB30 daysSame
pro10 GB90 daysSame
customper-contractper-contractper-contract

When a tenant’s backup pushes them over quota, the new backup is not uploaded — the schedule run reports a soft failure in the tenant’s UI and the cluster admin gets an alert in the super-admin console. Old backups are not auto-deleted to make room; tenants must delete manually or upgrade their plan.

Retention is enforced by a daily cleanup job (03:30 cluster time) that drops backup rows older than plan.max_retention_days and deletes the corresponding object from the license server’s store. A backup pinned by a super-admin (rare, for support investigations) is exempt.

The tenant’s Backups page is at https://<slug>.saas.proxrad.com/backups. It looks identical to the standalone Backups page. The differences are:

  • The storage indicator at the top shows the tenant’s quota usage, not the host’s free disk space.
  • The restore from another server option is grayed out unless the tenant has the backups.restore_from_other_tenant permission, which by default no one has — see the warning below.
  • The scheduled-backup options are restricted to daily, weekly, and monthly (no per-hour) on trial plans.

Per-tenant schedule rows live in tenant_<slug>.backup_schedules (the same table the standalone install uses). The cluster has one scheduler service that wakes every minute, iterates every tenant’s schedule table, computes “is now within this schedule’s run window in this tenant’s timezone?”, and queues backup jobs accordingly.

Crucially, each schedule honours its tenant’s system_timezone, not the host’s. Acme in Asia/Beirut and BigISP in America/New_York both running a 02:00 daily backup will fire at the right local time for each — see the v1.0.75 / v1.0.95 timezone fixes in Audit Findings & Hardening.

  1. Tenant admin goes to Backups in their panel.
  2. Picks a backup from the list.
  3. Clicks Restore.
  4. Confirms in the modal (this drops every table in tenant_<slug> and re-creates from the backup).
  5. The cluster’s API container:
    • Downloads the encrypted backup from the license server.
    • Decrypts using the tenant’s derived key (no manual key entry).
    • Pipes the decrypted SQL into psql -d proxpanel_saas with --single-transaction and a SET search_path TO tenant_<slug> prelude.
  6. Frontend warns the user to log out and back in (their sessions reference IDs that may have changed).

The whole restore takes about 10 seconds per 1,000 subscribers on warm hardware. The tenant’s other admins are logged out automatically; in-flight PPPoE sessions are unaffected (RADIUS reads from the now-restored data on the next auth packet).

A tenant who wants to move their data to a different SaaS cluster (or to a standalone install) downloads their V2 backup and contacts support:

  1. From the tenant panel: Backups → Download the latest backup. The file is decryptable only by entities that hold the cluster’s license key, so the file at rest is safe to email or upload to cloud storage temporarily.

  2. Support engineer on the destination cluster runs /scripts/import-saas-tenant.sh <slug> <backup-file>:

    • Creates the tenant row + schema if it doesn’t already exist (using onboarding pipeline).
    • Decrypts the V2 backup using the source cluster’s license key (super-admin enters it; not the tenant’s secret).
    • Loads the data into the new tenant_<slug> schema on the destination cluster.
  3. Cloudflare CNAME or DNS change for any custom domain is the tenant’s responsibility.

This flow is rare but documented. For a standalone migration, the destination uses the same V2 format but with the standalone install’s license key — see Backups.

The super-admin console surfaces backup health across the cluster:

AlertTrigger
Tenant over quotaAny tenant whose cloud_backups total bytes exceeds 100% of plan
Tenant backup failing repeatedlyThree consecutive failed scheduled-backup runs for the same tenant
Backup retention overflowThe host’s local backup-staging dir at /var/lib/proxpanel/backup-staging is over 80% full
License-server upload failedAPI container cannot reach license.proxrad.com for upload — backups are queued locally and retried

Local queueing lets the cluster survive transient license-server outages without losing backups. The queue is drained when the license server is reachable again. A queued-but-not-yet-uploaded backup is visible in the tenant’s UI with a “pending upload” badge.

A typical SaaS tenant with 1,000 subscribers and 6 months of accounting history produces a backup of about 50–80 MB compressed. With AES-256-GCM overhead and the V2 header, the on-disk size is roughly the same. Multiply by retention to estimate cluster storage:

Tenants × subscribersDaily backups, 30-day retentionTotal cloud storage per tenant
100 tenants × 1,000 subscribers30 × 60 MB = 1.8 GB~1.8 GB / tenant, 180 GB total
50 tenants × 5,000 subscribers30 × 300 MB = 9 GB~9 GB / tenant, 450 GB total
10 tenants × 25,000 subscribers30 × 1.5 GB = 45 GB~45 GB / tenant, 450 GB total

The license-server backup store sizes for the aggregate. Plan for headroom — tenants do not delete old backups voluntarily, and quota-overrun rejection happens at upload time, which surprises operators who haven’t sized correctly.

ScenarioRecovery pathApprox. RTO
A tenant accidentally deletes their subscriber databaseRestore from the most recent automated backup, lose at most 24 hours of data5 minutes
The SaaS cluster’s Postgres data corruptsRestore the host-level Postgres backup; tenant backups are not used (they’re per-schema)30 minutes
The whole SaaS host is lostStand up a fresh host, run the cluster install, restore the public.tenants schema from host-level backup, then restore each tenant’s data from cloud backups2–4 hours
A tenant claims their backup is corruptedTry restore in a dry-run mode (super-admin-only); if confirmed corrupted, fall back to the previous day’s backup10 minutes
License server is unreachable during a scheduled backupBackup queues locally; retried until the license server returnsn/a (transparent)

The recovery paths assume the cluster’s host-level Postgres backup is separately managed (it is — via the host’s own pg_basebackup to off-cluster storage). Tenant cloud backups alone cannot rebuild the cluster from scratch.

Backups are admin-only inside a tenant:

PermissionEffect
backups.viewSee the Backups page and the list of existing backups.
backups.createTrigger an on-demand backup or create a schedule.
backups.restoreRestore a backup. Destructive — confirms via modal.
backups.deleteDelete a backup from cloud storage. Frees quota.
backups.editEdit a schedule.

On the super-admin side, saas.backups.view lets you see the cluster-wide backup health page and force-trigger or restore on a tenant’s behalf.