Restoring SQLite Backups for Rails Apps Deployed with Kamal
In the previous article, I wrote about the first version of rails-kamal-sqlite-backups: a small set of scripts that copies SQLite database files from a Kamal storage volume into a local workspace.
That solved the first operational problem: creating predictable local backups.
The next problem is what happens when I actually need one of those backups.
Why Add Restore?
A backup script is only half of the recovery story.
If I have to rebuild a server, recover from a bad deploy, or restore a production environment from local files, I do not want to manually reconstruct the right rsync command from memory. That is exactly the kind of task where a small mistake can overwrite the wrong directory or push the wrong backup version.
So I added restore.sh.
The goal is not to automate the entire incident response process. I still want to decide when to restart the app, when to put the service in maintenance mode, and when to validate the restored database.
The script only handles one specific job:
Pick a local backup version and sync it back to the configured Kamal storage directory.
The Restore Source
The backup structure stays the same:
~/Projects/<app>-workspace/db_backups/<app>/<timestamp>/
For example:
~/Projects/billing-workspace/db_backups/billing/2026-05-30T020000-0600/
production.sqlite3
production.sqlite3-wal
production.sqlite3-shm
restore.sh looks inside:
$LOCAL_BACKUP_ROOT/$APP_NAME
Then it lists the timestamped backup directories from newest to oldest.
The Remote Target
The restore target is the same REMOTE_BACKUP_DIR value used by the backup script.
In my Kamal apps, the persistent storage volume is usually mounted like this:
volumes:
- "/storage:/rails/storage"
The Rails container sees:
/rails/storage
The host sees:
/storage
So the project config keeps:
REMOTE_BACKUP_DIR=/storage
During restore, the script uploads the selected backup directory into:
$SERVER_USER@$SERVER_HOST:$REMOTE_BACKUP_DIR/
Preview First
Before restoring anything, I can run a dry run:
./restore.sh config/projects/billing.env --dry-run
The script still asks which backup version I want to restore, but rsync runs with --dry-run, so no files are uploaded.
This gives me a quick way to confirm the selected version and remote destination before making changes on the server.
Restore with Confirmation
The real restore command is:
./restore.sh config/projects/billing.env
The script shows the available backup versions:
Available backups for billing:
1) 2026-05-30T020000-0600
2) 2026-05-29T020000-0600
3) 2026-05-28T020000-0600
After selecting one, it prints the selected local path and the remote target.
Before uploading, it requires a typed confirmation:
RESTORE billing
That extra friction is intentional. A restore command is destructive by nature, even when the script is small and predictable.
Optional Safety Copy
Before syncing the selected backup to the server, restore.sh can create a remote safety copy of the current SQLite files.
When enabled, it stores those files under:
$REMOTE_BACKUP_DIR/pre_restore_<timestamp>/
For example:
/storage/pre_restore_2026-05-30T143000-0600/
This is not a substitute for a real backup policy, but it gives me one more local rollback point on the server before overwriting the active storage files.
What the Script Does Not Do
The script does not restart the application.
That is deliberate. Restoring database files is one part of recovery, but the right application restart flow depends on the situation. I may want to stop the app first, inspect the files, restart with Kamal, or perform extra checks before traffic comes back.
Keeping restart out of restore.sh makes the script safer and easier to reason about.
The Full Flow
For a real recovery, the flow looks like this:
- Identify the backup version.
- Run a dry run.
- Run the restore command.
- Choose whether to create a remote safety copy.
- Type the restore confirmation.
- Restart or validate the app manually.
The important part is that the risky command is now captured in a repeatable script instead of being recreated under pressure.
Closing the Loop
The first version of rails-kamal-sqlite-backups focused on getting SQLite files out of the server and into a project workspace.
Adding restore closes the loop:
backup.shpulls SQLite files from the Kamal storage volume.cleanup.shkeeps old local backup directories under control.restore.shpushes a selected local backup back to the server when recovery is needed.
It is still a small tool, and that is the point. The operational path is visible, the config is per project, and the restore step now has enough guardrails to be useful without trying to own the entire recovery process.