Backups and restore
Pre-deploy snapshots, the nightly encrypted backup to R2, how to restore, and the restore drill that proves a backup works.
Palui keeps two kinds of backup: a local snapshot before every deploy, and an encrypted nightly copy off the machine.
Pre-deploy snapshots
Before each deploy, scripts/pre-deploy.sh snapshots the database with the app's CLI, run inside the app container:
node .output/server/cli.mjs backup /data/backups/pre-deploy-<UTC>.db.gzIt keeps the 3 newest snapshots in /data/backups/. They're your quick way back if a deploy goes wrong. See Upgrades.
Nightly encrypted backup
Every night a job snapshots the database with the app's CLI and makes a tar archive of the WhatsApp auth directory. It encrypts both files with age, uploads them to Cloudflare R2, and checks that the uploaded size matches.
scripts/backup.sh runs every night at 03:30. On a macOS Docker host, ops/install-backup.sh copies it out of the checkout and installs it as a launchd agent, and scripts/pre-deploy.sh reinstalls it whenever the script changes. Each run:
- Snapshots the database with
node .output/server/cli.mjs backupinside theappcontainer, copies it out and checks the gzip. - Streams a tar of
/data/wa-auth/out of theruntimecontainer straight into age, so the plaintext never lands on the host's disk. No auth dir (WhatsApp not paired) means no archive, and that's fine. - Encrypts the database snapshot with age to your public key. The WhatsApp archive was already encrypted as it streamed.
- Uploads them to the R2 bucket
palui-backupsaspalui/<date>/palui-<timestamp>.db.gz.ageandpalui/<date>/wa-auth-<timestamp>.tar.gz.age. - Checks that each uploaded size matches the local file, then deletes every temp file.
It refuses to run with under 500 MB free and logs every step to its own log file, trimmed once it passes 1 MB.
Setting up the key
The backup only ever needs the public key, which the job reads from ~/.config/palui/age-recipient in your own home directory (override it with AGE_RECIPIENT_FILE). The private key (the age identity) goes straight into the macOS Keychain, never into a file on the host or the repo:
brew install age
mkdir -p ~/.config/palui
security add-generic-password -s palui-backup-age-identity -a palui \
-w "$(age-keygen 2>/dev/null | grep AGE-SECRET-KEY)"
security find-generic-password -s palui-backup-age-identity -w \
| age-keygen -y > ~/.config/palui/age-recipientKeep a second copy of the identity in your password manager. A backup you can't decrypt is no backup.
The WhatsApp archive is a login
The wa-auth archive on its own is enough to take over the bot's WhatsApp number. Never store or move it unencrypted, and treat the age key like a password.
Restore
You need the backup file, the age private key, and the same MASTER_KEY the backup was made with. Without the matching MASTER_KEY, the database restores but no connector secret decrypts.
-
Download the backup from R2 and decrypt it:
age -d -i <(security find-generic-password -s palui-backup-age-identity -w) \ -o palui.db.gz <encrypted-db-backup> gunzip palui.db.gz -
Stop both services:
docker compose stop runtime app. -
Replace
/data/palui.dbon the volume with the restored file. Make sure nopalui.db-walorpalui.db-shmfrom the old database is left next to it. -
If you're also restoring WhatsApp, decrypt the
wa-autharchive the same way and extract it to/data/wa-auth/. Otherwise, pair WhatsApp again after the restore. -
Start again with
docker compose up -d --waitand check/health.
Restoring a pre-deploy snapshot works the same way, minus the decryption step.
The restore drill
A backup only counts once it's been restored. The drill does that without touching production. Run it after setting up backups, and again whenever you want proof:
scripts/restore-drill.sh [file]With no argument it takes the newest database backup in R2 from the last 7 days. With a file (.db.gz.age, .db.gz or .db) it uses that instead. It needs the production MASTER_KEY in its environment, because the point is to prove the backup and the key still go together, and the image already built on the host. It:
- Decrypts the backup with the identity from the Keychain.
- Restores it into a scratch compose project named
palui-drill, with its own volume and the app on127.0.0.1:4899. - Waits for
/healthto answer 200. - Runs
node .output/server/cli.mjs secrets-check, which tries every connector and webhook secret and exits non-zero if any of them fails to decrypt. - Tears the scratch project down with its volume, whatever happened.
- Prints
PASSorFAIL.
Only the app service runs in the drill. Starting the runtime would open a second Telegram poller and a second WhatsApp session against the real bot.
A FAIL at step 4 usually means the MASTER_KEY doesn't match the one the backup was made with.