Skip to content

Back Up & Restore Tenants

Requires a license with the tenant-backup feature. See pricing.

This page takes one tenant out of the instance and puts it back: as a portable SQL file you download, as an encrypted archive in an S3 bucket, or as a clone into a new tenant on the same instance. A file taken on PostgreSQL loads on MySQL or SQL Server, and the other way round. These routes cover one tenant's content types and entries. Back up the whole database with your database provider's own tooling.

  • Every route needs the admin or super_admin role. An admin works on their own tenant. Naming another tenant needs super_admin and otherwise answers 403, for example cross-tenant export requires super_admin.
  • Without tenant-backup every route here answers 402. If you set LYEVE_PLUGINS, include multitenant. See Licensing and tiers.
  • import and restore replace a tenant's data, so they take a signed-in session. An admin token or an API key cannot call them.
  • import and validate carry the dump inside a JSON body, so the instance's JSON limit applies first: MAX_JSON_BODY_BYTES, 1 MiB by default, answers 413 above it. To load a larger dump, raise it (it applies to every JSON route) and keep it within MAX_BODY_BYTES, 10 MiB by default. The routes' own ceilings are 500 MiB for import and 50 MiB for validate.
  • With the web application firewall running, its SQL injection rules refuse validate and import with 403 and request blocked by WAF, because the body is SQL. Add an exception for these paths before you load a dump.
  • The feature is in beta.
  1. Download the tenant as one SQL document, table definitions and rows:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/tenant-backup/export \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"include_data": true, "target_dialect": "postgres"}' \
    -o tenant-backup.sql

    The answer is application/sql with an attachment name such as tenant-acme-backup-20261002-091515.sql. The file opens with comments naming the tenant and both dialects, and ends with a summary line such as -- Export summary: 2 tables, 2 rows, elapsed 19.29ms.

  2. Check what it holds before you rely on it:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/tenant-backup/validate \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d "$(jq -Rs '{sql: .}' tenant-backup.sql)"

    Nothing is written. The answer counts statements_executed and rows_inserted, and lists any errors, each with its statement_no, a message and a preview of the statement.

Export fieldMeaning
tenant_idThe tenant to export. Empty means your own. Another tenant needs super_admin.
schemasContent types to include. Empty means all.
include_datatrue adds the rows. Otherwise the file holds only the table definitions.
target_dialectpostgres, mysql, mssql, or empty for the source database's own. Anything else answers 400 with invalid target_dialect.

GET /api/admin/tenant-backup/tables lists the tenant's tables with their row and column counts.

Load into a staging tenant first and check it there. Never truncate a production tenant on a dump you have not loaded somewhere else.

  1. Dry-run the load. dry_run parses and counts, and writes nothing:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/tenant-backup/import \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d "$(jq -Rs '{tenant_id: "acme_restore", dry_run: true, sql: .}' tenant-backup.sql)"
  2. Run it for real by dropping dry_run. Add truncate to empty each target table first, or skip_existing to leave any table that already has rows alone.

    The answer reports statements_executed, statements_skipped, rows_inserted, rows_foreign_tenant (rows in the file that belong to another tenant, which are not loaded), any errors, and elapsed.

Import fieldEffect
tenant_idThe target tenant. Empty means your own.
sqlThe dump. Required, or the call answers 400 with sql is required.
dry_runParse and count only.
truncateEmpty each target table before loading. This deletes the existing data.
skip_existingLeave any table that already has rows untouched.

A backup job exports a tenant, checksums it, encrypts it and uploads it to an S3 bucket each time it runs. When REPLICATION_TARGETS is set, the archive is also copied to each enabled target.

  1. Create the job:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/tenant-backup/jobs \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"name": "nightly", "cron_expr": "0 3 * * *", "include_data": true,
    "retention_days": 30, "s3_bucket": "lyeve-backups", "s3_prefix": "backups"}'

    The answer is 201 with the job and its id. name and s3_bucket are required. When the instance sets STORAGE_S3_BUCKET, a job may write only to that bucket or one listed in STORAGE_S3_BACKUP_ALLOWED_BUCKETS. Any other answers 400 with s3_bucket "<name>" is not in the allowed backup buckets list.

  2. Run it:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/tenant-backup/jobs/$JOB_ID/trigger \
    -H "Authorization: Bearer $TOKEN"

    The answer is 202 with a run_id, and the backup runs after it, for up to 30 minutes.

  3. Follow the run with GET /api/admin/tenant-backup/runs/{id}. Its status moves from running to completed or failed. A failed run's message points at the server log, which has the detail.

  4. Check the archive it wrote:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/tenant-backup/verify \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"archive_id": "'"$ARCHIVE_ID"'"}'

    The answer carries checksum_ok and sql_valid. List archives with GET /api/admin/tenant-backup/archives.

Archives are encrypted with a key derived for each tenant from the instance's ENCRYPTION_KEY, or by AWS KMS when KMS_PROVIDER=aws. Keep that key safe: an archive cannot be restored without it.

  1. Dry-run the restore into a staging tenant:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/tenant-backup/restore \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"archive_id": "'"$ARCHIVE_ID"'", "target_tenant": "acme_restore", "dry_run": true}'
  2. Drop dry_run to restore. Add "truncate": true to empty the target tables first. target_tenant empty means your own, and another tenant needs super_admin. The answer has the same counts as an import.

A restore test takes the tenant's newest archive that is at least an hour old, checks its checksum and SQL, and loads it into a test tenant, then records passed or failed with the tables and rows it imported.

Terminal window
curl -X POST http://localhost:3001/api/admin/tenant-backup/restore-tests/schedules \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"schedule_cron": "0 4 * * 1", "test_tenant": "acme_restore_test", "timeout_sec": 600, "enabled": true}'

Run it with POST .../restore-tests/schedules/{id}/trigger, which answers with the result once the test finishes, and read past results at GET .../restore-tests/results. timeout_sec defaults to 600.

A clone copies a source tenant's content types and entries into a new tenant on the same instance, without a file. Use it to seed a staging tenant from production or to stand up a demo. It copies content only, not what other features keep for the tenant.

A clone creates its target, so it needs super_admin, and anyone else answers 403 with only super_admin may provision new tenants. A real run also needs multitenant-provisioning and room under the tenant ceiling, and otherwise answers 402.

  1. Validate:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/tenant-clone/validate \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"source_tenant_id": "acme", "target_tenant_id": "acme_staging"}'
    {"valid": true, "issues": null}

    Validation checks that the source exists and has content types, and that the target slug is valid and not taken. Each issue carries a severity of error or warning, a category and a message. An error blocks the clone.

  2. Dry-run it. Every check runs and nothing is written:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/tenant-clone/clone \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"source_tenant_id": "acme", "target_tenant_id": "acme_staging",
    "target_name": "Acme Staging", "dry_run": true}'
    {"source_tenant": "acme", "target_tenant": "acme_staging", "dry_run": true,
    "schemas_cloned": 1, "rows_cloned": 2, "rows_skipped": 0, "elapsed": "1.49ms"}
  3. Run it with "dry_run": false and curl -N. The answer streams Server-Sent Events, phase, schema and row while it works, and ends with event: done carrying the same counts, or event: error.

Every copied entry gets a new id, and relations point at the copies. If a live clone fails after creating the target, the target is removed so you can retry the same slug.

Clone fieldMeaning
source_tenant_id, target_tenant_id, target_nameRequired.
target_planThe new tenant's plan. Defaults to free.
dry_runtrue runs every check and answers JSON.
schemasContent types to copy. Empty means all.
skip_contentCopy the content types without their entries.
field_mappingA list of {"source_schema", "target_schema", "fields": [{"source_field", "target_field"}]} that renames fields on the way.
VariableWhat it doesDefault
STORAGE_S3_BUCKETThe bucket backups go to, and the only one a job may name unless the next setting adds more.unset
STORAGE_S3_BACKUP_ALLOWED_BUCKETSComma-separated extra buckets a job may write to.none
STORAGE_S3_REGION, STORAGE_S3_ENDPOINTRegion and a custom endpoint, for an S3-compatible service.unset
STORAGE_S3_KEY, STORAGE_S3_SECRETS3 credentials.unset
STORAGE_S3_FORCE_PATH_STYLEPath-style S3 addressing.false
KMS_PROVIDERaws for AWS KMS, or empty for local AES-256-GCM encryption.local
KMS_REGION, KMS_KEY_ID, KMS_ENDPOINTAWS KMS settings.unset
REPLICATION_TARGETSA JSON array of copies to make of every archive, each {"region", "bucket", "prefix", "endpoint", "access_key", "secret_key", "path_style", "use_ssl", "enabled"}. Only enabled targets are used.none

Object storage covers the S3 settings in full.

Backup, restore and clone
MethodPathPurpose
POST/api/admin/tenant-backup/exportDownload a tenant as SQL.
POST/api/admin/tenant-backup/validateParse a dump and count it. Nothing is written.
POST/api/admin/tenant-backup/importLoad a dump into a tenant. Session only.
GET/api/admin/tenant-backup/tablesThe tenant's tables with row and column counts.
GET/api/admin/tenant-backup/progressFollow a running export or import as Server-Sent Events, with ?op_id=.
GET, POST/api/admin/tenant-backup/jobsList or create backup jobs.
GET, PUT, DELETE/api/admin/tenant-backup/jobs/{id}Read, change or delete a job.
POST/api/admin/tenant-backup/jobs/{id}/triggerRun a job now.
GET/api/admin/tenant-backup/runs, /runs/{id}List runs or read one.
GET/api/admin/tenant-backup/archives, /archives/{id}List archives or read one.
GET/api/admin/tenant-backup/archives/{id}/downloadDownload an archive.
DELETE/api/admin/tenant-backup/archives/{id}Delete an archive.
POST/api/admin/tenant-backup/restoreRestore an archive into a tenant. Session only.
POST/api/admin/tenant-backup/verifyCheck an archive's checksum and SQL.
POST/api/admin/tenant-backup/retention/cleanupDelete archives past their retention now.
GET/api/admin/tenant-backup/replication/statusReplication targets. Super admin only.
GET, POST/api/admin/tenant-backup/restore-tests/schedulesList or create restore-test schedules.
GET, PUT, DELETE/api/admin/tenant-backup/restore-tests/schedules/{id}Read, change or delete one.
POST/api/admin/tenant-backup/restore-tests/schedules/{id}/triggerRun a restore test now.
GET/api/admin/tenant-backup/restore-tests/resultsRestore-test results.
POST/api/admin/tenant-clone/validateCheck a clone.
POST/api/admin/tenant-clone/cloneClone a tenant.

Archives and exports contain the tenant's full data, personal data included. Store them where only operators can read them.