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:

  1. Identify the backup version.
  2. Run a dry run.
  3. Run the restore command.
  4. Choose whether to create a remote safety copy.
  5. Type the restore confirmation.
  6. 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:

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.