Skip to content

Backups and Storage

Theodor Dimitriou edited this page Sep 24, 2026 · 2 revisions

Where AstroBaaS keeps its data, how to choose a storage driver, and how to back up, restore and recover. It is for operators running an install in production.

The references are STORAGE.md and the backup section of deploy/README.md.

The data layer

An install's data is three things:

Part Where Override
The database db.json (lowdb), or a SQLite/libSQL database set by DATABASE_URL DB_PATH, DATABASE_URL
Public uploads public/uploads/<yyyy>/<mm>/<hash>.<ext> UPLOADS_DIR
Private uploads (files attached to form submissions) private-uploads/ beside the database PRIVATE_UPLOADS_DIR

Also keep a copy of your environment file (for example shared/.env). It holds AUTH_SECRET, payment keys and SMTP credentials, which cannot be re-created from the database.

All application code goes through one Storage contract (src/core/storage.ts), so the driver can be swapped without touching routes, plugins or the admin. Pending schema migrations run automatically at startup on whichever driver is active. See Upgrading.

Storage drivers

DATABASE_URL DATABASE_DRIVER Driver Use for
unset none lowdb (JSON file at DB_PATH) Zero-config local development
file:./data/astrobaas.db unset libSQL doc-blob (local SQLite) A durable single host
libsql://<db>.turso.io + DATABASE_AUTH_TOKEN unset libSQL doc-blob (remote, Turso) Durable and portable between hosts
file: or libsql://… relational relational libSQL (one row per entity) Production, multiple writers or hosts

All three drivers run in CI through the smoke suite (npm run smoke, npm run smoke:libsql, npm run smoke:relational).

Choosing one

  • lowdb keeps everything in one JSON file on local disk. It cannot be shared between instances and is lost on ephemeral or serverless filesystems.
  • doc-blob stores the whole dataset as one JSON row. It is durable, but writes are last-write-wins across processes.
  • relational (DATABASE_DRIVER=relational) stores each collection as its own table of (id, data) rows. Two writers touching different rows do not overwrite each other. It is the recommended production driver.
# Production example (Turso, relational)
DATABASE_URL=libsql://my-app.turso.io
DATABASE_AUTH_TOKEN=...
DATABASE_DRIVER=relational
AUTH_SECRET=$(openssl rand -hex 32)
NODE_ENV=production

On first boot against an empty database, AstroBaaS creates the schema and the initial admin account itself. There is no separate migration step.

Local SQLite files

Every file: database is opened with journal_mode=WAL, busy_timeout=5000 and synchronous=NORMAL.

  • On the relational driver, a second writer (an import CLI, a second replica) waits for the lock rather than failing.
  • On doc-blob, a second writer silently loses data, because each write replaces the single row. Stop the site before running anything that writes, or move to the relational driver. npm run import:woo -- <dir> --apply refuses a doc-blob database unless you pass --site-stopped.
  • Readers, backups and the shared rate-limit store are safe beside a running site on either driver.

Backup from the admin

Admin → Tools → Backup → Download backup calls GET /api/backup/export (admin-only). What you get depends on the driver:

Install Archive
lowdb (no DATABASE_URL) JSON: { db, uploads }, the database plus every file under the uploads directory
A file: DATABASE_URL (doc-blob or relational) A VACUUM INTO snapshot of the SQLite file (kind: 'libsql-file'), plus uploads
A remote libsql:// (Turso) database Refused: there is no local file. Use turso db dump or the provider's tools.

Limits:

  • Upload files larger than 10 MB are left out, and the archive says so.
  • If the uploads folder is over about 256 MB, uploads are left out with a clear note and only the database is backed up. Sync uploads separately (rsync, rclone).
  • Private uploads are not in the archive. Copy PRIVATE_UPLOADS_DIR separately.

Restore from the admin

Admin → Tools → Restore calls POST /api/backup/import (admin-only). It overwrites the current database and any media files with the same name. The largest accepted archive is 256 MB.

Archive Site running on Result
JSON (lowdb) lowdb Restored
JSON (lowdb) any DATABASE_URL 400. libSQL would ignore the file.
SQLite snapshot a file: DATABASE_URL The database file is replaced. Restart the site afterwards: other processes and in-memory caches still hold the old data.
SQLite snapshot lowdb, or a remote libsql:// 400

Restoring an archive also sanitises stored HTML and SVG on the way in. Restore expects a backup from the same version of AstroBaaS.

Off-site backups (S3-compatible)

A backup on the same disk is a copy, not a backup. AstroBaaS can push archives on a schedule to any S3-compatible bucket, such as Backblaze B2, Cloudflare R2, MinIO, Hetzner or Wasabi. Configuration is environment only; nothing about the target is stored in the database.

BACKUP_S3_ENDPOINT=https://s3.eu-central-003.backblazeb2.com
BACKUP_S3_BUCKET=my-shop-backups
BACKUP_S3_KEY_ID=...
BACKUP_S3_SECRET=...
# Optional
BACKUP_S3_REGION=us-east-1
BACKUP_S3_PREFIX=astrobaas/myshop
BACKUP_S3_PATH_STYLE=1          # MinIO and similar
BACKUP_EVERY_HOURS=24
BACKUP_KEEP=14
Variable Default Notes
BACKUP_S3_ENDPOINT, BACKUP_S3_BUCKET, BACKUP_S3_KEY_ID, BACKUP_S3_SECRET none All four are required, or off-site backup stays off.
BACKUP_S3_REGION us-east-1
BACKUP_S3_PREFIX astrobaas The key prefix inside the bucket.
BACKUP_S3_PATH_STYLE off 1 for path-style addressing.
BACKUP_S3_ALLOW_HTTP off Only https endpoints are accepted unless this is 1 (for a MinIO on your own network).
BACKUP_EVERY_HOURS 24 Hours between runs (up to 30 days).
BACKUP_KEEP 14 Archives to keep (up to 365). Older ones are deleted after a successful upload.
BACKUP_ATTEMPT_TIMEOUT_MS 3600000 How long an unfinished attempt blocks a new one.

How it behaves:

  • It backs up whatever the active driver stores (the same rules as the admin export). A remote Turso database is reported as "not supported for this driver".
  • Runs happen on the scheduler, and only one process per database runs them (a lease).
  • The history is kept in the database, so a restart does not trigger a fresh backup. A backup cut off by a crash or deploy is retried after an hour.
  • The last result is shown on Admin → Background jobs and in /api/health/deep as offsite_backup. A warning there means the last backup failed, or the last success is more than two intervals old.
  • POST /api/backup/run (admin) runs the same backup now.
  • Archives from off-site backups can be restored through Admin → Tools → Restore, following the same rules.

Put the bucket with a different provider or in a different region from the server.

Backing up the database yourself

If you back up with your own tooling, do not copy a live SQLite file with cp. In WAL mode the newest commits sit in the -wal file. Take a consistent snapshot instead:

sqlite3 data/astrobaas.db "VACUUM INTO '/backups/astrobaas.db'"
# or
sqlite3 data/astrobaas.db ".backup '/backups/astrobaas.db'"

For lowdb, a copy of db.json is atomic enough. A nightly copy should cover the database, uploads/ and private-uploads/. Keep the env file separately and encrypted.

Moving between drivers

The backup tools will not do this. Export depends on the driver and restore works only onto the same kind of driver. That is deliberate: the alternative is a restore that reports success and changes nothing the site reads.

Ways to move content instead:

  • Through the API. Every route uses the same Storage contract whatever the driver, so reading from the old install and writing to the new one works. It is the only route that works while both are running. See Headless-and-REST-API.
  • Through the importers. npm run import:wp and npm run import:woo write to whatever DATABASE_URL and DATABASE_DRIVER say. If the content came from WordPress or WooCommerce, re-importing is often less work. (npm run import:md, npm run setup and npm run reset-password refuse when DATABASE_URL is set.) See Importing.
  • Between two libSQL databases with the same DATABASE_DRIVER (a file: one and Turso, either way), the schema is the same. Use the database's own tools (turso db dump, VACUUM INTO). Doc-blob and relational are different schemas.
  • Uploads always travel separately. rsync both UPLOADS_DIR and PRIVATE_UPLOADS_DIR. File records store paths relative to those directories, so moving each directory whole is enough.

Per-collection CSV export and import of posts, pages and custom content types is also available (/api/transfer/{collection}). Import matches rows by id, then slug, and shows a preview before anything is written.

Disaster recovery

A backup nobody has restored is a hope. Practise these steps on a scratch install now and then.

  1. Stand up the same version. Deploy the release that made the backup (or check Upgrading for the path forward). See Installation and Deployment.
  2. Restore the environment file. Use the same AUTH_SECRET; without it, existing sessions are invalid (survivable). Restore payment, SMTP and backup credentials from your encrypted copy.
  3. Restore the database.
    • lowdb: put db.json back, or restore the JSON archive through Admin → Tools → Restore.
    • file: SQLite: put the snapshot file at the DATABASE_URL path while the site is stopped, or restore the snapshot archive through the admin and then restart.
    • Turso: restore with Turso's tools.
  4. Restore uploads. Copy UPLOADS_DIR and PRIVATE_UPLOADS_DIR back into place. With nginx, point /uploads/ at the shared directory, not at a release's dist/client/uploads.
  5. Start and check. GET /readyz returns 503 until the database is at the schema version the build expects. Then check GET /api/health/deep for warnings (offsite_backup, email_channel, cors_origins, payment_return_urls).
  6. Run the mail test (node dist/mail-test.mjs you@example.com) and place a test order if the shop is on.
  7. Confirm off-site backups resume, and note that the first scheduled run happens once one is due.

See also

Clone this wiki locally