Backups and restores
A Raytha site has two stores of data: the PostgreSQL database and the file storage for media. Back up both, keep your configuration somewhere safe, and test a restore before you need one.
What to back up
| What | Where it lives | Why it matters |
|---|---|---|
| The database | PostgreSQL, one database (raytha in the examples) | Content and drafts and their revisions, content types, views, users and roles, themes and templates, site pages, menus, Raytha Functions, webhooks and delivery log, authentication schemes, settings, audit and email logs, and the data-protection keys that sign users in. |
| File storage | The raytha_user_uploads volume (/app/user-uploads) for Local; the bucket or container for S3 and Azure Blob | The bytes of every media-library file and every theme asset. The database holds only a row per file. |
| Configuration | Your .env, Compose file or platform variables | Connection string, SMTP, storage credentials, proxy settings. Not stored by Raytha. |
You do not need to back up the container image, the admin bundle, or any cache. Rebuild them from the same Raytha version.
The database also holds what makes a restored site work immediately. The data-protection keys are stored in the DataProtectionKeys table, so after a restore people who were signed in stay signed in and encrypted values still decrypt. API keys are stored as hashes; their holders keep using the same keys.
Back up PostgreSQL
Use the custom format, which is compressed and restores selectively. Against the Compose database from the repository (container raytha-db, user postgres):
docker exec raytha-db pg_dump -U postgres -Fc raytha > raytha-$(date +%F).dump
Against any PostgreSQL server, from a machine with the client tools installed:
pg_dump -h db.example.com -p 5432 -U raytha -Fc -f raytha-$(date +%F).dump raytha
The dump is consistent: pg_dump reads a single snapshot, so you can run it while Raytha is serving traffic. Match the client's major version to the server's (PostgreSQL 17 for the repository's Compose files).
Back up media
Local storage
Archive the volume, mounted read-only so the backup cannot change it:
docker run --rm -v raytha_user_uploads:/data:ro -v "$PWD":/backup alpine \
tar czf /backup/uploads-$(date +%F).tgz -C /data .
If you set FILE_STORAGE_LOCAL_DIRECTORY to a host path, back up that directory with your usual file backup tool.
S3 and Azure Blob
Raytha does not back these up for you. Turn on your provider's own protection: object versioning and replication, or a scheduled copy to a second bucket. A copy with aws s3 sync, mc mirror or az storage blob download-batch works too. Keep object keys unchanged, because the database refers to files by key.
Order matters
The two backups are taken separately, so they cannot be one snapshot. Take the database dump first and the files second. Every file the dump refers to is then in the files backup; a file uploaded between the two is just an extra object that nothing points to.
A backup script
This script backs up the repository's Compose setup and removes backups older than 14 days. Override the variables for other setups, and drop step 2 for S3 or Azure Blob.
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/var/backups/raytha}"
DB_CONTAINER="${DB_CONTAINER:-raytha-db}"
UPLOADS_VOLUME="${UPLOADS_VOLUME:-raytha_user_uploads}"
KEEP_DAYS="${KEEP_DAYS:-14}"
STAMP="$(date +%F-%H%M)"
mkdir -p "$BACKUP_DIR"
# 1. Database first, so every file the dump refers to is still in the files backup below.
docker exec "$DB_CONTAINER" pg_dump -U postgres -Fc raytha > "$BACKUP_DIR/db-$STAMP.dump"
# 2. Uploaded files (skip this step if you use S3 or Azure Blob).
docker run --rm -v "$UPLOADS_VOLUME":/data:ro -v "$BACKUP_DIR":/backup alpine \
tar czf "/backup/uploads-$STAMP.tgz" -C /data .
# 3. Drop old backups.
find "$BACKUP_DIR" -name 'db-*.dump' -mtime +"$KEEP_DAYS" -delete
find "$BACKUP_DIR" -name 'uploads-*.tgz' -mtime +"$KEEP_DAYS" -delete
Run it nightly from cron, then copy BACKUP_DIR off the machine. A backup on the same disk does not survive the disk.
15 2 * * * /usr/local/bin/raytha-backup.sh
Treat backups as secrets
A database dump contains credentials, so encrypt it and restrict who can read it:
- The SMTP password saved in the admin (
OrganizationSettings.SmtpPassword) is stored as plain text. - JWT secret keys and SAML certificates for authentication schemes.
- Webhook signing secrets (
Webhooks.Secret), stored as plain text. - The data-protection keys. Someone with them can forge Raytha's sign-in cookies.
- Personal data: user emails, audit and email logs. Log retention (Settings, Maintenance) decides how much accumulates; the default is 180 days.
API keys are the exception: only their hashes are stored.
Restore
- Stop the app so nothing writes during the restore.
- Recreate an empty database and load the dump (command below).
- Restore the files into the same storage location, under the same keys.
- Start the same Raytha version the backup came from. If you want a newer version, upgrade afterwards: a dump from an older version contains an older schema, and
APPLY_PENDING_MIGRATIONSbrings it forward on start. - Check
/healthz/readyand open the site. See Health checks.
docker compose stop app
docker exec raytha-db dropdb -U postgres --if-exists raytha
docker exec raytha-db createdb -U postgres raytha
docker exec -i raytha-db pg_restore -U postgres -d raytha --no-owner < raytha-2026-10-02.dump
# Local storage: unpack into the volume
docker run --rm -v raytha_user_uploads:/data -v "$PWD":/backup:ro alpine \
tar xzf /backup/uploads-2026-10-02.tgz -C /data
docker compose start app
Use --no-owner when the restoring database user is not the one that created the dump. To restore into a different database name, change the name in createdb, pg_restore -d and your connection string together.
This is also the rollback for a failed upgrade.
Prove the backup works
An untested backup is a guess. Restore the latest dump into a scratch database, without touching production:
docker exec raytha-db createdb -U postgres raytha_check
docker exec -i raytha-db pg_restore -U postgres -d raytha_check --no-owner < raytha-2026-10-02.dump
docker exec raytha-db psql -U postgres -d raytha_check -At \
-c 'select count(*) as users from "Users"' \
-c 'select count(*) as content_items from "ContentItems"' \
-c 'select "MigrationId" from "__EFMigrationsHistory" order by 1 desc limit 1'
docker exec raytha-db dropdb -U postgres raytha_check
The counts should match your site and the last migration should be the one your Raytha version expects. For a stronger test, start a second Raytha against the scratch database and a copy of the files on another port, sign in, and open a page that shows an image.
Gotchas
- Restoring one half. A database restored without its files shows broken media; files restored without the database are orphans.
- The Website URL travels with the database. If you restore to a new domain, update the Website URL in Settings, Configuration, or emails and API media links keep pointing at the old host.
- Volumes are deleted by
docker compose down -v. So is your database. - The Compose database password is
changeme. Change it before the database is reachable from anywhere. See Deploy with Docker. - Managed PostgreSQL. Providers offer point-in-time recovery. Use it in addition to
pg_dump, not instead: a logical dump is the portable format you can restore anywhere.
Next steps
- Upgrading: take this backup before every upgrade.
- File storage: where the media bytes live.
- Configuration: log retention and everything else.