kamal-backup: Scheduled Rails Backups for Kamal Apps

kamal-backup: Scheduled Rails Backups for Kamal Apps

I released kamal-backup today.

I run Chat with Work on Kamal, and I needed backups. There are already Kamal accessories for database backups. None of them also back up Active Storage. None use restic, so encryption, deduplication, and repository checks are on you. None ship a CLI with restores and drills. None produce evidence you can hand a security reviewer.

So I built one.

A gem and a Docker image

kamal-backup is two pieces: a Ruby gem you add to your Rails app, and a Docker image you boot as a Kamal accessory. They point at a restic repository you bring yourself.

The gem is your CLI. Local commands run directly on your machine using restic. Production-side commands shell out through Kamal into the accessory. The same kamal-backup binary covers setup (init, validate), on-demand operations (backup, list, check), data movement (restore local, restore production), verification (drill local, drill production), and audit (evidence).

The Docker image (ghcr.io/crmne/kamal-backup) ships with restic, pg_dump, mariadb-dump/mysqldump, and sqlite3 baked in. The default container command is kamal-backup schedule, a loop that fires every backup_schedule_seconds and writes one database snapshot and one Active Storage file snapshot per run.

The restic repository is where the encrypted snapshots end up: S3-compatible object storage, a restic REST server, or a filesystem path. kamal-backup points at it. It doesn’t run it for you.

Why restic

I didn’t want to invent a backup format, and I didn’t want to bolt encryption and deduplication onto shell scripts. Restic does what I needed:

  • encrypted repositories by default;
  • a tag system, so the database dump and the Active Storage tree from the same run share a run:<timestamp> and pair up at restore time;
  • deduplication across runs, so a year of daily backups doesn’t grow linearly;
  • restic forget --prune for retention;
  • restic check for repository health;
  • S3-compatible storage, a restic REST server, or a local filesystem path, so you host the repository wherever fits.

It’s a single binary that drops cleanly into a Docker image, alongside the database client tools. Nothing extra to install on the Rails host. kamal-backup is the Rails- and Kamal-shaped layer on top, and restic does the cryptography, the storage, and the integrity checks.

Setting it up

Add the gem in development:

# Gemfile
group :development do
  gem "kamal-backup"
end

Run init. It creates config/kamal-backup.yml and prints an accessory block you paste into your Kamal deploy config:

bundle install
bundle exec kamal-backup init

config/kamal-backup.yml holds the backup settings:

accessory: backup
app_name: chatwithwork
database_adapter: postgres
database_url: postgres://chatwithwork@chatwithwork-db:5432/chatwithwork_production
backup_paths:
  - /data/storage
restic_repository: s3:https://s3.example.com/chatwithwork-backups
restic_init_if_missing: true
backup_schedule_seconds: 86400

Kamal mounts that file read-only into the accessory, so the accessory block in config/deploy.yml stays small. Only secrets live in env:

accessories:
  backup:
    image: ghcr.io/crmne/kamal-backup:latest
    host: chatwithwork.com
    files:
      - config/kamal-backup.yml:/app/config/kamal-backup.yml:ro
    env:
      secret:
        - PGPASSWORD
        - RESTIC_PASSWORD
        - AWS_ACCESS_KEY_ID
        - AWS_SECRET_ACCESS_KEY
    volumes:
      - "chatwithwork_storage:/data/storage:ro"
      - "chatwithwork_backup_state:/var/lib/kamal-backup"

Validate, boot, and watch the logs:

bundle exec kamal-backup validate
bin/kamal accessory boot backup
bin/kamal accessory logs backup

validate catches missing required settings before the accessory has to be running. Once it’s up, the container loops on kamal-backup schedule.

Then run the first backup and print evidence:

bundle exec kamal-backup backup
bundle exec kamal-backup list
bundle exec kamal-backup evidence

No cron glue. No separate backup host. No “remember to install restic on production.” The accessory image already has it.

Rails data, not just a database dump

A Rails app has two things worth backing up: the database, and file-backed Active Storage. kamal-backup handles both.

Postgres uses pg_dump. MySQL and MariaDB use mariadb-dump or mysqldump. SQLite uses sqlite3 .backup. File-backed Active Storage uses restic backup from mounted volumes.

Each run writes one database snapshot and one file snapshot, both tagged with app:<name>, type:database or type:files, and the same run:<timestamp>. You pair them at restore time using that timestamp.

If your app stores Active Storage blobs directly in S3, there’s no mounted path for backup_paths to capture. kamal-backup still covers the database. The S3 side is on your bucket lifecycle and replication settings.

Restores are part of the product

The backup script is the easy part. The restore path is where most setups fail.

So kamal-backup ships with restore commands:

bundle exec kamal-backup restore local
bundle exec kamal-backup restore production

restore local pulls a production backup down to your laptop. Useful when you want to inspect real data, reproduce a production bug, or prove the backup actually comes back.

restore production prompts before it overwrites anything.

Restore drills

The command I care about most is drill.

bundle exec kamal-backup drill local \
  --check "bin/rails runner 'puts User.count'"

A drill means: restore, check, record the result.

Two modes:

  • drill local restores onto your machine and runs an optional check.
  • drill production restores into scratch production-side targets, never the live database.

That second one matters. For Postgres and MySQL, you give it a scratch database. For SQLite, a scratch file path. For Active Storage, a scratch restore directory. The drill uses production infrastructure, without pointing at live production.

That’s the difference between “the backup ran” and “we restored the latest production snapshot into a scratch target on April 30, ran this check, and it passed.”

Evidence for reviews

I went through a security review for Chat with Work this year. The questions were fair:

  • What’s being backed up?
  • Where does it go?
  • Is it encrypted?
  • When did the last backup run?
  • When did the last repository check run?
  • When was the last restore drill?
  • Can you prove all of that without leaking secrets?

kamal-backup evidence prints redacted JSON: current backup settings, latest snapshots, latest restic check, latest restore drill, retention settings, tool versions.

bundle exec kamal-backup evidence

Secrets are redacted. The output is meant to land in an internal ops record or a CASA packet. Not a screenshot of a green cron job. Actual evidence.

Try it

# Gemfile
gem "kamal-backup"

Docs at kamal-backup.dev, source on GitHub.

Newsletter