kamal-backup 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 67d289224a384dc9f1546799c15b1bf69649060d49ff8018869b098924104704
4
- data.tar.gz: 4307158e1472c2aeafbab424fbb1d2af50a1a1cc6a43b2b11ea6dfae5e3b1d42
3
+ metadata.gz: c69dd196192b66424004ae3769d75602862227ad958e056288e1cb49b8242a8a
4
+ data.tar.gz: fa70cfffdf1d95212700244fcdfbb5e7b72b8f4eaf27f2898177c16c305fe9d8
5
5
  SHA512:
6
- metadata.gz: cba47d9ebf7771e7513e24ca6e7c1d30c5bc3660caeb6ab0f4ed945203baadf370778af5b887a7eb39a82664709f9210179c847c1983d29318befe03abe0e8fa
7
- data.tar.gz: ff55565395d49eca645732276db416aa2f240b7511468f7d96296821c776937404862835ba977bd7005efba7ea91fb2269f7e7b0f7f4db7e751cb1b375da9ea2
6
+ metadata.gz: c1764bd5d9a81b9d705838a27f857a679efebe7b2c3ff64be0f4faffc440678e1958124f0cb8ca81c24fa5e30380324d20d42447ae20e5b33f7d946eab7dcec4
7
+ data.tar.gz: f929aecfd15c9b2ed34560292217a97e4ceff31fd4fad1b9ce0e2589af862cd725a45a275c3755ddc744bb8f0e8703c187aa96537664431b93cc4a2841ae4627
data/README.md CHANGED
@@ -1,82 +1,66 @@
1
- # kamal-backup
1
+ <div align="center">
2
2
 
3
- `kamal-backup` gives Rails apps a clean backup and restore workflow for Kamal.
3
+ <img src="docs/assets/images/logo.svg" alt="kamal-backup" height="96">
4
4
 
5
- It backs up:
5
+ <h1>kamal-backup</h1>
6
6
 
7
- - PostgreSQL, MySQL/MariaDB, or SQLite
8
- - file-backed Active Storage and other mounted app files
7
+ <strong>The easiest way to run scheduled backups for a Rails app deployed with Kamal.</strong>
9
8
 
10
- It restores in two clear modes:
9
+ [![Gem Version](https://img.shields.io/gem/v/kamal-backup.svg)](https://rubygems.org/gems/kamal-backup)
10
+ [![CI](https://github.com/crmne/kamal-backup/actions/workflows/ci.yml/badge.svg)](https://github.com/crmne/kamal-backup/actions/workflows/ci.yml)
11
+ [![Docker Image](https://img.shields.io/badge/image-ghcr.io%2Fcrmne%2Fkamal--backup-blue)](https://github.com/crmne/kamal-backup/pkgs/container/kamal-backup)
12
+ [![Docs](https://img.shields.io/badge/docs-kamal--backup.dev-blue)](https://kamal-backup.dev)
13
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
11
14
 
12
- - `restore local`: pull a production backup onto your machine
13
- - `restore production`: restore back into live production
15
+ </div>
14
16
 
15
- And it drills in two clear modes:
17
+ ---
16
18
 
17
- - `drill local`: prove the backup works on your machine
18
- - `drill production`: restore into scratch targets on production infrastructure, run checks, and record evidence
19
+ Backups for Rails apps deployed with Kamal should not become a separate ops project.
19
20
 
20
- Under the hood it uses [restic](https://restic.net/) for encrypted backup storage and repository management.
21
+ `kamal-backup` is one Kamal accessory that runs encrypted backups for your Rails database and file-backed Active Storage files on a schedule. It also gives you restore drills and redacted evidence for security reviews like CASA.
22
+
23
+ If you already deploy with Kamal, backups should feel like adding one more accessory.
21
24
 
22
25
  ## Why Rails teams use it
23
26
 
24
- `kamal-backup` is aimed at the common self-hosted Rails setup where:
27
+ Most self-hosted Rails apps need the same things:
25
28
 
26
- - the app is deployed with Kamal
27
- - the database is PostgreSQL, MySQL/MariaDB, or SQLite
28
- - file data lives on a mounted volume
29
- - you need real restore drills and evidence for CASA or another security review
29
+ - scheduled backups for PostgreSQL, MySQL/MariaDB, or SQLite
30
+ - file-backed Active Storage backups from mounted volumes
31
+ - a fast way to restore production data locally
32
+ - restore drills that do not touch the live production database
33
+ - evidence that says more than "the backup job is green"
30
34
 
31
- If your app already stores Active Storage blobs directly in S3, there may be no local file path for `BACKUP_PATHS` to capture. In that case, `kamal-backup` still covers the database side, but object-storage backups are a separate concern.
35
+ `kamal-backup` packages that workflow into a small Ruby gem, a production accessory image, and a restic repository.
32
36
 
33
- ## Quick Start
37
+ ## Quick start
34
38
 
35
- Add the gem in your Rails app:
39
+ Add the gem:
36
40
 
37
- ```ruby
41
+ ```rb
38
42
  group :development do
39
43
  gem "kamal-backup"
40
44
  end
41
45
  ```
42
46
 
43
- Install it and generate the shared config stub:
47
+ Generate the config file and accessory snippet:
44
48
 
45
49
  ```sh
46
50
  bundle install
47
51
  bundle exec kamal-backup init
48
52
  ```
49
53
 
50
- That creates:
51
-
52
- - `config/kamal-backup.yml`
53
-
54
- For most Rails apps, that is enough. `restore local` and `drill local` can infer:
55
-
56
- - the development database target from `config/database.yml`
57
- - the local files target from `storage`
58
- - the local drill state directory from `tmp/kamal-backup`
59
-
60
- Only create `config/kamal-backup.local.yml` if you need to override those local defaults.
61
-
62
- Local restore and drill also require the `restic` binary on your machine. The backup accessory image already includes `restic` for production-side commands.
63
-
64
- Then add the backup accessory to `config/deploy.yml`:
54
+ Add the accessory to `config/deploy.yml`:
65
55
 
66
56
  ```yaml
67
57
  accessories:
68
58
  backup:
69
59
  image: ghcr.io/crmne/kamal-backup:latest
70
60
  host: chatwithwork.com
61
+ files:
62
+ - config/kamal-backup.yml:/app/config/kamal-backup.yml:ro
71
63
  env:
72
- clear:
73
- APP_NAME: chatwithwork
74
- DATABASE_ADAPTER: postgres
75
- DATABASE_URL: postgres://chatwithwork@chatwithwork-db:5432/chatwithwork_production
76
- BACKUP_PATHS: /data/storage
77
- RESTIC_REPOSITORY: s3:https://s3.example.com/chatwithwork-backups
78
- RESTIC_INIT_IF_MISSING: "true"
79
- BACKUP_SCHEDULE_SECONDS: "86400"
80
64
  secret:
81
65
  - PGPASSWORD
82
66
  - RESTIC_PASSWORD
@@ -84,227 +68,60 @@ accessories:
84
68
  - AWS_SECRET_ACCESS_KEY
85
69
  volumes:
86
70
  - "chatwithwork_storage:/data/storage:ro"
71
+ - "chatwithwork_backup_state:/var/lib/kamal-backup"
87
72
  ```
88
73
 
89
- Boot it:
90
-
91
- ```sh
92
- bin/kamal accessory boot backup
93
- bin/kamal accessory logs backup
94
- ```
95
-
96
- Run the first backup from your app checkout with the local gem and Kamal-style destination selection:
74
+ Put the backup settings in `config/kamal-backup.yml`:
97
75
 
98
- ```sh
99
- bundle exec kamal-backup -d production backup
100
- bundle exec kamal-backup -d production list
101
- bundle exec kamal-backup -d production evidence
76
+ ```yaml
77
+ accessory: backup
78
+ app_name: chatwithwork
79
+ database_adapter: postgres
80
+ database_url: postgres://chatwithwork@chatwithwork-db:5432/chatwithwork_production
81
+ backup_paths:
82
+ - /data/storage
83
+ restic_repository: s3:https://s3.example.com/chatwithwork-backups
84
+ restic_init_if_missing: true
85
+ backup_schedule_seconds: 86400
102
86
  ```
103
87
 
104
- If you keep multiple deploy configs, pass `-c` the same way Kamal does:
88
+ Boot it. The container runs `kamal-backup schedule` by default:
105
89
 
106
90
  ```sh
107
- bundle exec kamal-backup -c config/deploy.staging.yml -d staging backup
108
- ```
109
-
110
- Examples live in:
111
-
112
- - [examples/kamal-accessory.yml](examples/kamal-accessory.yml)
113
- - [examples/kamal-backup.yml.example](examples/kamal-backup.yml.example)
114
- - [examples/kamal-backup.local.yml.example](examples/kamal-backup.local.yml.example)
115
-
116
- ## What Restic Does Here
117
-
118
- `kamal-backup` uses restic as the backup engine and repository format.
119
-
120
- In the normal Kamal setup, you do not install restic on the Rails app host. The backup accessory image already includes it. You only point the accessory at a restic repository, usually:
121
-
122
- - S3-compatible object storage
123
- - a restic REST server
124
- - a filesystem path for local development
125
-
126
- If you choose a `rest:` repository, `kamal-backup` does not install or operate that server for you. It is a separate service.
127
-
128
- ## Commands
129
-
130
- The operator-facing command surface is:
131
-
132
- ```sh
133
- kamal-backup init
134
- kamal-backup backup
135
- kamal-backup restore local [snapshot-or-latest]
136
- kamal-backup restore production [snapshot-or-latest]
137
- kamal-backup drill local [snapshot-or-latest]
138
- kamal-backup drill production [snapshot-or-latest]
139
- kamal-backup list
140
- kamal-backup check
141
- kamal-backup evidence
142
- kamal-backup schedule
143
- kamal-backup version
91
+ bundle exec kamal-backup validate
92
+ bin/kamal accessory boot backup
93
+ bin/kamal accessory logs backup
144
94
  ```
145
95
 
146
- Production-side commands shell out through Kamal when you pass `-d` or `-c`. Local commands run on your machine.
147
-
148
- Remote-backed commands require the local gem version and the backup accessory version to match. If they drift, `kamal-backup` fails fast and tells you to reboot the accessory so it pulls the current `latest` image. `version` is the diagnostic exception: from an app checkout with `config/deploy.yml`, it shows both versions and the sync status even without `-d`.
149
-
150
- Common examples:
96
+ Run the first backup and print evidence:
151
97
 
152
98
  ```sh
153
99
  bundle exec kamal-backup -d production backup
154
- bundle exec kamal-backup -d production check
100
+ bundle exec kamal-backup -d production list
155
101
  bundle exec kamal-backup -d production evidence
156
- bundle exec kamal-backup -d production restore production latest
157
- bundle exec kamal-backup -d production drill production latest --database app_restore_20260423 --files /restore/files
158
- bundle exec kamal-backup -d production version
159
- bundle exec kamal-backup restore local latest
160
- bundle exec kamal-backup drill local latest --check "bin/rails runner 'puts User.count'"
161
- ```
162
-
163
- Use `kamal-backup help`, `kamal-backup help restore`, or `kamal-backup help drill` for task-specific usage.
164
-
165
- ## How a Backup Run Works
166
-
167
- When `kamal-backup backup` runs, it does five things:
168
-
169
- 1. Validates the app name, restic repository, database settings, and `BACKUP_PATHS`.
170
- 2. Creates a database backup with the database-native export tool.
171
- 3. Streams that database backup into restic with tags such as `type:database`, `adapter:<adapter>`, and `run:<timestamp>`.
172
- 4. Runs one `restic backup` for all configured `BACKUP_PATHS`, tagged as `type:files` with the same `run:<timestamp>`.
173
- 5. Optionally runs `restic forget --prune` and `restic check`.
174
-
175
- That shared `run:<timestamp>` tag lets you match the database backup and file backup from the same run.
176
-
177
- ## Restore
178
-
179
- `restore` means "put data back."
180
-
181
- `restore local` runs on your machine. With `-d` or `-c`, it asks Kamal for the backup accessory config and uses that as the source of truth for:
182
-
183
- - `APP_NAME`
184
- - `DATABASE_ADAPTER`
185
- - `RESTIC_REPOSITORY`
186
- - `LOCAL_RESTORE_SOURCE_PATHS` from the accessory `BACKUP_PATHS`
187
-
188
- For a normal Rails app, the local targets come from Rails conventions:
189
-
190
- - the development database in `config/database.yml`
191
- - `storage` as the local files target
192
- - `tmp/kamal-backup` as the local drill state directory
193
-
194
- You still provide the local secrets yourself in env:
195
-
196
- - `RESTIC_PASSWORD`
197
- - `POSTGRES_PASSWORD` or `MYSQL_PWD` when needed
198
- - `RESTIC_REPOSITORY` when it is not visible through `kamal config`
199
-
200
- And you need `restic` installed locally and available on `PATH`.
201
-
202
- Example:
203
-
204
- ```sh
205
- bundle exec kamal-backup -d production restore local latest
206
- ```
207
-
208
- `restore production` is the emergency path back into the live production database and live production file paths:
209
-
210
- ```sh
211
- bundle exec kamal-backup -d production restore production latest
212
- ```
213
-
214
- It prompts locally, then shells out through Kamal to the backup accessory.
215
-
216
- ## Restore Drills
217
-
218
- `drill` means "restore, check, and record the result."
219
-
220
- `drill local` is often the fastest proof for a small app:
221
-
222
- ```sh
223
- bundle exec kamal-backup -d production drill local latest --check "bin/rails runner 'puts User.count'"
224
- ```
225
-
226
- Like `restore local`, this runs on your machine and requires a local `restic` install.
227
-
228
- `drill production` restores into scratch targets on production infrastructure. It does not touch the live production database:
229
-
230
- ```sh
231
- bundle exec kamal-backup -d production drill production latest \
232
- --database app_restore_20260423 \
233
- --files /restore/files \
234
- --check "test -d /restore/files/data/storage"
235
102
  ```
236
103
 
237
- Every drill writes `last_restore_drill.json` under `KAMAL_BACKUP_STATE_DIR`, and `kamal-backup evidence` includes that latest result.
238
-
239
- ## Evidence for CASA and Similar Reviews
240
-
241
- `evidence` is the JSON summary you can attach to an ops record or security review.
104
+ ## What you get
242
105
 
243
- It includes:
106
+ - **Scheduled backups:** the accessory runs continuously and backs up on `backup_schedule_seconds`.
107
+ - **Database and Active Storage coverage:** database dumps plus file-backed Active Storage files from mounted volumes.
108
+ - **Restic underneath:** encrypted, deduplicated snapshots in S3-compatible storage, a restic REST server, or a filesystem repository.
109
+ - **Local restores:** pull production backups into your local Rails app when you need to inspect real data.
110
+ - **Restore drills:** restore into scratch production-side targets and record the result.
111
+ - **Security review evidence:** `kamal-backup evidence` prints redacted JSON with latest snapshots, checks, drills, retention, and tool versions.
244
112
 
245
- - latest database and file snapshots
246
- - latest `restic check` result
247
- - latest restore drill result
248
- - retention settings
249
- - tool versions
250
-
251
- For many reviews, the useful sequence is:
252
-
253
- 1. scheduled backups
254
- 2. repository checks
255
- 3. a real restore drill
256
- 4. `kamal-backup evidence`
257
-
258
- That reads much better to a reviewer than "the backup job is green."
259
-
260
- ## Configuration Highlights
261
-
262
- Core accessory environment:
263
-
264
- ```sh
265
- APP_NAME=chatwithwork
266
- DATABASE_ADAPTER=postgres
267
- RESTIC_REPOSITORY=s3:https://s3.example.com/chatwithwork-backups
268
- RESTIC_PASSWORD=change-me
269
- BACKUP_PATHS=/data/storage
270
- ```
271
-
272
- PostgreSQL:
273
-
274
- ```sh
275
- DATABASE_ADAPTER=postgres
276
- DATABASE_URL=postgres://app@app-db:5432/app_production
277
- PGPASSWORD=change-me
278
- ```
279
-
280
- MySQL/MariaDB:
281
-
282
- ```sh
283
- DATABASE_ADAPTER=mysql
284
- DATABASE_URL=mysql2://app@app-mysql:3306/app_production
285
- MYSQL_PWD=change-me
286
- ```
287
-
288
- SQLite:
289
-
290
- ```sh
291
- DATABASE_ADAPTER=sqlite
292
- SQLITE_DATABASE_PATH=/data/db/production.sqlite3
293
- ```
294
-
295
- Optional local config files:
113
+ ## Docs
296
114
 
297
- - `config/kamal-backup.yml`
298
- - `config/kamal-backup.local.yml`
115
+ Read the full documentation at **[kamal-backup.dev](https://kamal-backup.dev)**.
299
116
 
300
- `config/kamal-backup.local.yml` is only for nonstandard local targets. Keep secrets such as `RESTIC_PASSWORD`, cloud credentials, and local DB passwords in environment variables, not in YAML files.
117
+ Start here:
301
118
 
302
- ## Docs
119
+ - [Getting Started](https://kamal-backup.dev/getting-started/)
120
+ - [How Backups Work](https://kamal-backup.dev/how-backups-work/)
121
+ - [Configuration](https://kamal-backup.dev/configuration/)
122
+ - [Restore Drills](https://kamal-backup.dev/restore-drills/)
123
+ - [Commands](https://kamal-backup.dev/commands/)
303
124
 
304
- Full docs live in [`docs/`](docs/):
125
+ ## License
305
126
 
306
- - [`docs/_guide/getting-started.md`](docs/_guide/getting-started.md)
307
- - [`docs/_guide/configuration.md`](docs/_guide/configuration.md)
308
- - [`docs/_guide/restore.md`](docs/_guide/restore.md)
309
- - [`docs/_guide/restore-drills.md`](docs/_guide/restore-drills.md)
310
- - [`docs/_reference/commands.md`](docs/_reference/commands.md)
127
+ MIT
@@ -47,6 +47,11 @@ module KamalBackup
47
47
  true
48
48
  end
49
49
 
50
+ def validate(check_files: true)
51
+ config.validate_backup(check_files: check_files)
52
+ true
53
+ end
54
+
50
55
  def restore_to_local_machine(snapshot = "latest")
51
56
  validate_local_machine_restore
52
57
  require_restic!
@@ -33,8 +33,30 @@ module KamalBackup
33
33
 
34
34
  def local_command_config
35
35
  @local_command_config ||= begin
36
- defaults = deployment_mode? ? bridge.local_restore_defaults(accessory_name: accessory_name) : {}
37
- Config.new(env: command_env, defaults: defaults)
36
+ if deployment_mode?
37
+ Config.new(
38
+ env: command_env,
39
+ defaults: production_source_defaults,
40
+ config_paths: [Config::LOCAL_CONFIG_PATH]
41
+ )
42
+ else
43
+ Config.new(env: command_env)
44
+ end
45
+ end
46
+ end
47
+
48
+ def production_source_defaults
49
+ shared_config_source_defaults.merge(bridge.local_restore_defaults(accessory_name: accessory_name))
50
+ end
51
+
52
+ def shared_config_source_defaults
53
+ config = Config.new(env: {}, config_paths: [Config::SHARED_CONFIG_PATH], load_project_defaults: false)
54
+
55
+ {}.tap do |defaults|
56
+ defaults["APP_NAME"] = config.app_name if config.app_name
57
+ defaults["DATABASE_ADAPTER"] = config.database_adapter if config.database_adapter
58
+ defaults["RESTIC_REPOSITORY"] = config.restic_repository if config.restic_repository
59
+ defaults["LOCAL_RESTORE_SOURCE_PATHS"] = config.backup_paths.join("\n") if config.backup_paths.any?
38
60
  end
39
61
  end
40
62
 
@@ -58,6 +80,10 @@ module KamalBackup
58
80
  deployment_mode? || default_deploy_config?
59
81
  end
60
82
 
83
+ def validate_deploy_mode?
84
+ deployment_mode? || default_deploy_config?
85
+ end
86
+
61
87
  def accessory_name
62
88
  @accessory_name ||= bridge.accessory_name(preferred: local_preferences.accessory_name)
63
89
  end
@@ -103,6 +129,15 @@ module KamalBackup
103
129
  puts("fix: #{accessory_reboot_command}") if status == "out of sync"
104
130
  end
105
131
 
132
+ def validate_deploy_config
133
+ config = Config.new(
134
+ env: bridge.accessory_environment(accessory_name: accessory_name),
135
+ config_paths: [Config::SHARED_CONFIG_PATH],
136
+ load_project_defaults: false
137
+ )
138
+ config.validate_backup(check_files: false)
139
+ end
140
+
106
141
  def confirm!(message)
107
142
  return if options[:yes]
108
143
 
@@ -149,9 +184,15 @@ module KamalBackup
149
184
 
150
185
  def shared_config_template
151
186
  <<~YAML
152
- # Shared defaults for kamal-backup in this app.
153
- # Set this when your accessory is not named "backup".
154
187
  accessory: backup
188
+ app_name: your-app
189
+ database_adapter: postgres
190
+ database_url: postgres://your-app@your-db:5432/your_app_production
191
+ backup_paths:
192
+ - /data/storage
193
+ restic_repository: s3:https://s3.example.com/your-app-backups
194
+ restic_init_if_missing: true
195
+ backup_schedule_seconds: 86400
155
196
  YAML
156
197
  end
157
198
 
@@ -161,15 +202,9 @@ module KamalBackup
161
202
  backup:
162
203
  image: ghcr.io/crmne/kamal-backup:#{VERSION}
163
204
  host: your-server.example.com
205
+ files:
206
+ - config/kamal-backup.yml:/app/config/kamal-backup.yml:ro
164
207
  env:
165
- clear:
166
- APP_NAME: your-app
167
- DATABASE_ADAPTER: postgres
168
- DATABASE_URL: postgres://your-app@your-db:5432/your_app_production
169
- BACKUP_PATHS: /data/storage
170
- RESTIC_REPOSITORY: s3:https://s3.example.com/your-app-backups
171
- RESTIC_INIT_IF_MISSING: "true"
172
- BACKUP_SCHEDULE_SECONDS: "86400"
173
208
  secret:
174
209
  - PGPASSWORD
175
210
  - RESTIC_PASSWORD
@@ -177,6 +212,7 @@ module KamalBackup
177
212
  - AWS_SECRET_ACCESS_KEY
178
213
  volumes:
179
214
  - "your_app_storage:/data/storage:ro"
215
+ - "your_app_backup_state:/var/lib/kamal-backup"
180
216
  YAML
181
217
  end
182
218
  end
@@ -195,15 +231,15 @@ module KamalBackup
195
231
  CLI.basename
196
232
  end
197
233
 
198
- desc "local [SNAPSHOT]", "Restore the backup into the local database and local files"
234
+ desc "local [SNAPSHOT]", "Restore the backup into the local database and Active Storage path"
199
235
  def local(snapshot = "latest")
200
- confirm!("Restore #{snapshot} into the local database and local files? This will overwrite local data.")
236
+ confirm!("Restore #{snapshot} into the local database and Active Storage path? This will overwrite local data.")
201
237
  puts(JSON.pretty_generate(local_app.restore_to_local_machine(snapshot)))
202
238
  end
203
239
 
204
- desc "production [SNAPSHOT]", "Restore the backup into the production database and production files"
240
+ desc "production [SNAPSHOT]", "Restore the backup into the production database and Active Storage path"
205
241
  def production(snapshot = "latest")
206
- confirm!("Restore #{snapshot} into the production database and production files? This will overwrite production data.")
242
+ confirm!("Restore #{snapshot} into the production database and Active Storage path? This will overwrite production data.")
207
243
 
208
244
  if deployment_mode?
209
245
  exec_remote(["kamal-backup", "restore", "production", snapshot, "--yes"])
@@ -229,7 +265,7 @@ module KamalBackup
229
265
 
230
266
  method_option :database, type: :string, desc: "Scratch database name for PostgreSQL or MySQL"
231
267
  method_option :"sqlite-path", type: :string, desc: "Scratch SQLite path for production-side drills"
232
- method_option :files, type: :string, default: "/restore/files", desc: "Scratch files target for the drill"
268
+ method_option :files, type: :string, default: "/restore/files", desc: "Scratch Active Storage target for the drill"
233
269
  method_option :check, type: :string, desc: "Run a verification command after the restore"
234
270
  desc "production [SNAPSHOT]", "Run a restore drill on production infrastructure using scratch targets"
235
271
  def production(snapshot = "latest")
@@ -299,7 +335,7 @@ module KamalBackup
299
335
  class_option :config_file, aliases: "-c", type: :string, desc: "Path to Kamal deploy config file"
300
336
  class_option :destination, aliases: "-d", type: :string, desc: "Kamal destination to use"
301
337
  remove_command :tree
302
- desc "restore SUBCOMMAND ...ARGS", "Restore a backup onto the local machine or into production"
338
+ desc "restore SUBCOMMAND ...ARGS", "Restore a database and Active Storage backup locally or into production"
303
339
  subcommand "restore", RestoreCLI
304
340
  desc "drill SUBCOMMAND ...ARGS", "Run a restore drill on the local machine or on production infrastructure"
305
341
  subcommand "drill", DrillCLI
@@ -323,7 +359,7 @@ module KamalBackup
323
359
 
324
360
  include Helpers
325
361
 
326
- desc "backup", "Run one backup immediately"
362
+ desc "backup", "Run one database and Active Storage backup immediately"
327
363
  def backup
328
364
  if deployment_mode?
329
365
  exec_remote(["kamal-backup", "backup"])
@@ -350,7 +386,7 @@ module KamalBackup
350
386
  end
351
387
  end
352
388
 
353
- desc "evidence", "Print redacted operational evidence as JSON"
389
+ desc "evidence", "Print redacted backup, check, and restore-drill evidence as JSON"
354
390
  def evidence
355
391
  if deployment_mode?
356
392
  exec_remote(["kamal-backup", "evidence"])
@@ -359,7 +395,18 @@ module KamalBackup
359
395
  end
360
396
  end
361
397
 
362
- desc "init", "Create kamal-backup config stubs for local restore and drill commands"
398
+ desc "validate", "Validate backup configuration without running a backup"
399
+ def validate
400
+ if validate_deploy_mode?
401
+ validate_deploy_config
402
+ else
403
+ direct_app.validate
404
+ end
405
+
406
+ puts("ok")
407
+ end
408
+
409
+ desc "init", "Create config and print the scheduled backup accessory snippet"
363
410
  def init
364
411
  write_init_file(shared_config_path, shared_config_template)
365
412
 
@@ -368,7 +415,8 @@ module KamalBackup
368
415
  puts
369
416
  puts deploy_snippet
370
417
  puts
371
- puts "For most Rails apps, restore local and drill local can infer the development database, storage path, and tmp state directory."
418
+ puts "The accessory runs scheduled database and Active Storage backups with backup_schedule_seconds."
419
+ puts "For most Rails apps, restore local and drill local can infer the development database, Active Storage path, and tmp state directory."
372
420
  puts "Local restore and drill also require the restic binary on your machine."
373
421
  puts "Create config/kamal-backup.local.yml only if you need to override those local defaults."
374
422
  end
@@ -14,7 +14,9 @@ module KamalBackup
14
14
  }.freeze
15
15
 
16
16
  SUSPICIOUS_BACKUP_PATHS = %w[/ /var /etc /root /usr /bin /sbin /boot /dev /proc /sys /run].freeze
17
- DEFAULT_CONFIG_PATHS = %w[config/kamal-backup.yml config/kamal-backup.local.yml].freeze
17
+ SHARED_CONFIG_PATH = "config/kamal-backup.yml"
18
+ LOCAL_CONFIG_PATH = "config/kamal-backup.local.yml"
19
+ DEFAULT_CONFIG_PATHS = [SHARED_CONFIG_PATH, LOCAL_CONFIG_PATH].freeze
18
20
  YAML_KEY_ALIASES = {
19
21
  "app_name" => "APP_NAME",
20
22
  "database_adapter" => "DATABASE_ADAPTER",
@@ -24,7 +26,10 @@ module KamalBackup
24
26
  "local_restore_source_paths" => "LOCAL_RESTORE_SOURCE_PATHS",
25
27
  "accessory" => "KAMAL_BACKUP_ACCESSORY",
26
28
  "restic_repository" => "RESTIC_REPOSITORY",
29
+ "restic_repository_file" => "RESTIC_REPOSITORY_FILE",
27
30
  "restic_password" => "RESTIC_PASSWORD",
31
+ "restic_password_file" => "RESTIC_PASSWORD_FILE",
32
+ "restic_password_command" => "RESTIC_PASSWORD_COMMAND",
28
33
  "restic_init_if_missing" => "RESTIC_INIT_IF_MISSING",
29
34
  "restic_check_after_backup" => "RESTIC_CHECK_AFTER_BACKUP",
30
35
  "restic_check_read_data_subset" => "RESTIC_CHECK_READ_DATA_SUBSET",
@@ -45,9 +50,10 @@ module KamalBackup
45
50
 
46
51
  attr_reader :env
47
52
 
48
- def initialize(env: ENV, cwd: Dir.pwd, defaults: {})
53
+ def initialize(env: ENV, cwd: Dir.pwd, defaults: {}, config_paths: nil, load_project_defaults: true)
49
54
  raw_env = env.to_h
50
- @env = project_defaults(cwd: cwd).merge(defaults.to_h).merge(load_config_files(raw_env, cwd: cwd)).merge(raw_env)
55
+ base = load_project_defaults ? project_defaults(cwd: cwd) : {}
56
+ @env = base.merge(defaults.to_h).merge(load_config_files(raw_env, cwd: cwd, paths: config_paths)).merge(raw_env)
51
57
  end
52
58
 
53
59
  def app_name
@@ -66,10 +72,22 @@ module KamalBackup
66
72
  value("RESTIC_REPOSITORY")
67
73
  end
68
74
 
75
+ def restic_repository_file
76
+ value("RESTIC_REPOSITORY_FILE")
77
+ end
78
+
69
79
  def restic_password
70
80
  value("RESTIC_PASSWORD")
71
81
  end
72
82
 
83
+ def restic_password_file
84
+ value("RESTIC_PASSWORD_FILE")
85
+ end
86
+
87
+ def restic_password_command
88
+ value("RESTIC_PASSWORD_COMMAND")
89
+ end
90
+
73
91
  def restic_init_if_missing?
74
92
  truthy?("RESTIC_INIT_IF_MISSING")
75
93
  end
@@ -176,16 +194,16 @@ module KamalBackup
176
194
  end
177
195
  end
178
196
 
179
- def validate_restic
197
+ def validate_restic(check_files: true)
180
198
  required_app_name
181
- required_value("RESTIC_REPOSITORY")
182
- required_value("RESTIC_PASSWORD")
199
+ validate_restic_repository(check_files: check_files)
200
+ validate_restic_password(check_files: check_files)
183
201
  end
184
202
 
185
- def validate_backup
186
- validate_restic
187
- validate_database_backup
188
- validate_backup_paths
203
+ def validate_backup(check_files: true)
204
+ validate_restic(check_files: check_files)
205
+ validate_database_backup(check_files: check_files)
206
+ validate_backup_paths(check_files: check_files)
189
207
  end
190
208
 
191
209
  def validate_local_machine_restore
@@ -193,7 +211,7 @@ module KamalBackup
193
211
  validate_local_machine_paths
194
212
  end
195
213
 
196
- def validate_database_backup
214
+ def validate_database_backup(check_files: true)
197
215
  case database_adapter
198
216
  when "postgres"
199
217
  unless value("DATABASE_URL") || value("PGDATABASE")
@@ -205,13 +223,13 @@ module KamalBackup
205
223
  end
206
224
  when "sqlite"
207
225
  path = required_value("SQLITE_DATABASE_PATH")
208
- raise ConfigurationError, "SQLITE_DATABASE_PATH does not exist: #{path}" unless File.file?(path)
226
+ raise ConfigurationError, "SQLITE_DATABASE_PATH does not exist: #{path}" if check_files && !File.file?(path)
209
227
  else
210
228
  raise ConfigurationError, "DATABASE_ADAPTER is required or must be detectable from DATABASE_URL/SQLITE_DATABASE_PATH"
211
229
  end
212
230
  end
213
231
 
214
- def validate_backup_paths
232
+ def validate_backup_paths(check_files: true)
215
233
  paths = backup_paths
216
234
  raise ConfigurationError, "BACKUP_PATHS must contain at least one path" if paths.empty?
217
235
 
@@ -220,7 +238,7 @@ module KamalBackup
220
238
  if SUSPICIOUS_BACKUP_PATHS.include?(expanded) && !allow_suspicious_backup_paths?
221
239
  raise ConfigurationError, "refusing suspicious backup path #{expanded}; set KAMAL_BACKUP_ALLOW_SUSPICIOUS_PATHS=true to override"
222
240
  end
223
- raise ConfigurationError, "backup path does not exist: #{path}" unless File.exist?(path)
241
+ raise ConfigurationError, "backup path does not exist: #{path}" if check_files && !File.exist?(path)
224
242
  end
225
243
  end
226
244
 
@@ -288,22 +306,48 @@ module KamalBackup
288
306
  RailsApp.new(cwd: cwd).defaults
289
307
  end
290
308
 
291
- def load_config_files(raw_env, cwd:)
292
- config_paths(raw_env, cwd: cwd).each_with_object({}) do |path, merged|
309
+ def load_config_files(raw_env, cwd:, paths:)
310
+ config_paths(raw_env, cwd: cwd, paths: paths).each_with_object({}) do |path, merged|
293
311
  next unless File.file?(path)
294
312
 
295
313
  merged.merge!(normalize_config_file(path))
296
314
  end
297
315
  end
298
316
 
299
- def config_paths(raw_env, cwd:)
300
- if explicit = raw_env["KAMAL_BACKUP_CONFIG"]
317
+ def config_paths(raw_env, cwd:, paths:)
318
+ if paths
319
+ Array(paths).map { |path| File.expand_path(path, cwd) }
320
+ elsif explicit = raw_env["KAMAL_BACKUP_CONFIG"]
301
321
  [File.expand_path(explicit, cwd)]
302
322
  else
303
323
  DEFAULT_CONFIG_PATHS.map { |relative| File.expand_path(relative, cwd) }
304
324
  end
305
325
  end
306
326
 
327
+ def validate_restic_repository(check_files:)
328
+ return if restic_repository
329
+
330
+ if path = restic_repository_file
331
+ raise ConfigurationError, "RESTIC_REPOSITORY_FILE does not exist: #{path}" if check_files && !File.file?(path)
332
+
333
+ return
334
+ end
335
+
336
+ raise ConfigurationError, "RESTIC_REPOSITORY or RESTIC_REPOSITORY_FILE is required"
337
+ end
338
+
339
+ def validate_restic_password(check_files:)
340
+ return if restic_password || restic_password_command
341
+
342
+ if path = restic_password_file
343
+ raise ConfigurationError, "RESTIC_PASSWORD_FILE does not exist: #{path}" if check_files && !File.file?(path)
344
+
345
+ return
346
+ end
347
+
348
+ raise ConfigurationError, "RESTIC_PASSWORD, RESTIC_PASSWORD_FILE, or RESTIC_PASSWORD_COMMAND is required"
349
+ end
350
+
307
351
  def normalize_config_file(path)
308
352
  data = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
309
353
  return {} if data.nil?
@@ -4,6 +4,7 @@ require_relative "command"
4
4
  module KamalBackup
5
5
  class KamalBridge
6
6
  DEFAULT_CONFIG_FILE = "config/deploy.yml"
7
+ VERSION_LINE_PATTERN = /\A\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*\z/
7
8
 
8
9
  def initialize(redactor:, config_file: nil, destination: nil)
9
10
  @redactor = redactor
@@ -42,13 +43,17 @@ module KamalBackup
42
43
  end
43
44
  end
44
45
 
46
+ def accessory_environment(accessory_name:)
47
+ accessory_secret_placeholders(accessory_name).merge(accessory_clear_env(accessory_name))
48
+ end
49
+
45
50
  def execute_on_accessory(accessory_name:, command:)
46
51
  capture_kamal(kamal_exec_argv(accessory_name, command))
47
52
  end
48
53
 
49
54
  def remote_version(accessory_name:)
50
55
  result = execute_on_accessory(accessory_name: accessory_name, command: "kamal-backup version")
51
- version = result.stdout.to_s.strip
56
+ version = parse_version_line(result.stdout)
52
57
 
53
58
  if version.empty?
54
59
  raise ConfigurationError, "could not determine remote kamal-backup version from accessory #{accessory_name}"
@@ -71,13 +76,23 @@ module KamalBackup
71
76
  end
72
77
 
73
78
  def accessory_clear_env(accessory_name)
74
- accessory = accessories.fetch(accessory_name) do
79
+ normalize_env(fetch(accessory_env(accessory_name), :clear) || {})
80
+ end
81
+
82
+ def accessory_secret_placeholders(accessory_name)
83
+ normalize_secret_env(fetch(accessory_env(accessory_name), :secret))
84
+ end
85
+
86
+ def accessory_env(accessory_name)
87
+ fetch(accessory(accessory_name), :env) || {}
88
+ end
89
+
90
+ def accessory(accessory_name)
91
+ accessories.fetch(accessory_name) do
75
92
  accessories.fetch(accessory_name.to_sym) do
76
93
  raise ConfigurationError, "accessory #{accessory_name.inspect} is not defined in #{config_file || DEFAULT_CONFIG_FILE}"
77
94
  end
78
95
  end
79
-
80
- normalize_env(fetch(fetch(accessory, :env) || {}, :clear) || {})
81
96
  end
82
97
 
83
98
  def normalize_env(values)
@@ -86,6 +101,19 @@ module KamalBackup
86
101
  end
87
102
  end
88
103
 
104
+ def normalize_secret_env(values)
105
+ case values
106
+ when Hash
107
+ values.each_with_object({}) { |(key, _value), env| env[key.to_s] = "configured" }
108
+ when Array
109
+ values.each_with_object({}) { |key, env| env[key.to_s] = "configured" }
110
+ when String, Symbol
111
+ { values.to_s => "configured" }
112
+ else
113
+ {}
114
+ end
115
+ end
116
+
89
117
  def fetch(hash, key)
90
118
  hash[key] || hash[key.to_s] || hash[key.to_sym]
91
119
  end
@@ -127,5 +155,9 @@ module KamalBackup
127
155
  Command.capture(spec, redactor: @redactor)
128
156
  end
129
157
  end
158
+
159
+ def parse_version_line(output)
160
+ output.to_s.lines.map(&:strip).reverse.find { |line| line.match?(VERSION_LINE_PATTERN) }.to_s
161
+ end
130
162
  end
131
163
  end
@@ -6,6 +6,8 @@ require_relative "command"
6
6
 
7
7
  module KamalBackup
8
8
  class Restic
9
+ RESTIC_ENV_PATTERN = /\A(?:RESTIC_|AWS_|B2_|AZURE_|GOOGLE_|RCLONE_|OS_|ST_|HP_|HTTP_|HTTPS_|NO_PROXY)/i
10
+
9
11
  attr_reader :config, :redactor
10
12
 
11
13
  def initialize(config, redactor:)
@@ -25,13 +27,19 @@ module KamalBackup
25
27
  end
26
28
 
27
29
  def backup_stream(command, filename:, tags:)
28
- restic_command = CommandSpec.new(argv: ["restic", "backup", "--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags))
30
+ restic_command = CommandSpec.new(
31
+ argv: ["restic", "backup", "--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags),
32
+ env: restic_env
33
+ )
29
34
  log("backing up stream as #{filename}")
30
35
  pipe_commands(command, restic_command, producer_label: "dump", consumer_label: "restic backup")
31
36
  end
32
37
 
33
38
  def backup_file(path, filename:, tags:)
34
- command = CommandSpec.new(argv: ["restic", "backup", "--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags))
39
+ command = CommandSpec.new(
40
+ argv: ["restic", "backup", "--stdin", "--stdin-filename", filename] + tag_args(common_tags + tags),
41
+ env: restic_env
42
+ )
35
43
  log("backing up file content as #{filename}")
36
44
 
37
45
  File.open(path, "rb") do |file|
@@ -126,12 +134,12 @@ module KamalBackup
126
134
  end
127
135
 
128
136
  def pipe_dump_to_command(snapshot, filename, command)
129
- restic_command = CommandSpec.new(argv: ["restic", "dump", snapshot, filename])
137
+ restic_command = CommandSpec.new(argv: ["restic", "dump", snapshot, filename], env: restic_env)
130
138
  pipe_commands(restic_command, command, producer_label: "restic dump", consumer_label: command.argv.first)
131
139
  end
132
140
 
133
141
  def write_dump_to_path(snapshot, filename, target_path)
134
- command = CommandSpec.new(argv: ["restic", "dump", snapshot, filename])
142
+ command = CommandSpec.new(argv: ["restic", "dump", snapshot, filename], env: restic_env)
135
143
  target_path = File.expand_path(target_path)
136
144
  FileUtils.mkdir_p(File.dirname(target_path))
137
145
  temp_path = "#{target_path}.kamal-backup-#{$$}.tmp"
@@ -160,7 +168,7 @@ module KamalBackup
160
168
  end
161
169
 
162
170
  def run(args)
163
- Command.capture(CommandSpec.new(argv: ["restic"] + args), redactor: redactor)
171
+ Command.capture(CommandSpec.new(argv: ["restic"] + args, env: restic_env), redactor: redactor)
164
172
  end
165
173
 
166
174
  def common_tags
@@ -172,6 +180,12 @@ module KamalBackup
172
180
  tags.compact.each_with_object([]) { |tag, args| args.concat(["--tag", tag]) }
173
181
  end
174
182
 
183
+ def restic_env
184
+ config.env.each_with_object({}) do |(key, value), env|
185
+ env[key] = value if key.to_s.match?(RESTIC_ENV_PATTERN)
186
+ end
187
+ end
188
+
175
189
  def pipe_commands(producer, consumer, producer_label:, consumer_label:)
176
190
  Open3.popen3(producer.env, *producer.argv) do |producer_stdin, producer_stdout, producer_stderr, producer_wait|
177
191
  producer_stdin.close
@@ -1,3 +1,3 @@
1
1
  module KamalBackup
2
- VERSION = "0.1.2"
2
+ VERSION = "0.2.1"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kamal-backup
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - crmne
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-04-25 00:00:00.000000000 Z
11
+ date: 2026-04-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -38,8 +38,8 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
- description: Back up PostgreSQL, MySQL, SQLite, and mounted Rails file data into restic,
42
- then run deliberate restores and restore drills.
41
+ description: Back up PostgreSQL, MySQL, SQLite, and file-backed Active Storage files
42
+ into restic on a schedule, then run restore drills and produce review evidence.
43
43
  email:
44
44
  executables:
45
45
  - kamal-backup
@@ -92,5 +92,6 @@ requirements: []
92
92
  rubygems_version: 3.5.22
93
93
  signing_key:
94
94
  specification_version: 4
95
- summary: Rails-friendly encrypted backups and restore drills for Kamal apps
95
+ summary: Scheduled backups, restore drills, and evidence for Rails apps deployed with
96
+ Kamal
96
97
  test_files: []