Commit 03757af164
03757af164f2d817cda2373ef88ed2292c7c38cc
parent: 12a6859743
Verified · cmc ci/build: success ci/test: failure
cmc <hello@cleberg.net> · 2026-09-28 21:34 UTC
deploy, wiki: encrypted archives in the backup scripts and docs
Closes #274
Layout: unified · split
.gitbay/wiki/Admin.org
+21
| @@ -179,6 +179,15 @@ anything. The delivery queue (a device's undelivered and attempted |
| 179 | 179 | pushes) is capped the same way the mail queue is, by =[retention] |
| 180 | 180 | push=. |
| 181 | 181 | |
| 182 | ** [backup] |
| 183 | - =age_recipients= (optional) — age public keys (=age1...=). When set, |
| 184 | =admin backup= encrypts every archive to them and appends =.age= to |
| 185 | its name. Generate the pair off the host with =age-keygen=; only the |
| 186 | public key goes here, so the host writes archives it cannot read. |
| 187 | The restic copy is unaffected: the offsite job stages its own |
| 188 | =VACUUM INTO= of the live database and snapshots =/var/lib/gitbay=, |
| 189 | not the archives. |
| 190 | |
| 182 | 191 | ** [api] |
| 183 | 192 | - =enabled= (false) — the JSON API surface; see [[API]]. Off |
| 184 | 193 | means no credential-bearing HTTP endpoint exists at all. |
| @@ -442,6 +451,18 @@ integrity check, and every repository the snapshot names must be in the |
| 442 | 451 | archive. A database-only archive is checked for integrity and says so. |
| 443 | 452 | Exit is non-zero on damage or a missing repository. |
| 444 | 453 | |
| 454 | With =[backup] age_recipients= set the archive is =<name>.tar.gz.age= |
| 455 | and =--verify= needs the private key: |
| 456 | |
| 457 | #+begin_src sh |
| 458 | gitbayd admin backup --verify gitbay-20260927-090000.tar.gz.age --identity ~/.config/gitbay/backup-identity.txt |
| 459 | age -d -i ~/.config/gitbay/backup-identity.txt gitbay-20260927-090000.tar.gz.age | tar -xz -C /new/root |
| 460 | #+end_src |
| 461 | |
| 462 | The identity lives off the host (with the secret key file and the |
| 463 | restic credentials), so verifying an encrypted archive happens there |
| 464 | or on a restore host. |
| 465 | |
| 445 | 466 | Restore: extract into an empty directory, point =server.root= at it, |
| 446 | 467 | start gitbayd. Host keys are preserved, so clients keep their |
| 447 | 468 | known_hosts entries; hooks regenerate at startup. |
.gitbay/wiki/Architecture/06-Data-and-Cryptography.org
+1 −1
| @@ -45,7 +45,7 @@ throttling (=internal/sshd/sshd.go=). |
| 45 | 45 | | API tokens, sessions, login links, email codes, invites | SHA-256 of a 256-bit random value; the value is shown once and never stored (=internal/store/sessions.go=) | |
| 46 | 46 | | CI secrets, webhook secrets, mirror tokens, APNs device tokens | AES-256-GCM under a key file outside the database and outside =server.root=; additional data binds table, column and row (=internal/seal=, =internal/store/secrets.go=) | |
| 47 | 47 | | SQLite file | mode 0640, directory 0750 | |
| 48 | | | Backups | the local archive is not encrypted; restic encrypts the offsite copy | |
| 48 | | Backups | local archives age-encrypted when =[backup] age_recipients= is set; restic encrypts the offsite copy; neither carries the secret key file, whose offsite copy is a separate keys repository | |
| 49 | 49 | | Disk | no application-level encryption; any disk encryption is the host's | |
| 50 | 50 | |
| 51 | 51 | The database file or a backup read by anyone other than the =gitbay= |
.gitbay/wiki/Architecture/08-Operations.org
+2 −2
| @@ -48,8 +48,8 @@ the product activity feed, not an audit trail. |
| 48 | 48 | |
| 49 | 49 | | Item | Schedule | Kept | Contents | |
| 50 | 50 | |-----------------+----------+------+-----------------------------------------------------------------| |
| 51 | | | Full archive | nightly | 7 | SQLite snapshot (=VACUUM INTO=), all repositories, LFS, SSH host keys | |
| 52 | | | Database only | hourly | 48 | SQLite snapshot | |
| 51 | | Full archive | nightly | 7 | SQLite snapshot (=VACUUM INTO=), all repositories, LFS, SSH host keys; age-encrypted when =[backup] age_recipients= is set | |
| 52 | | Database only | hourly | 48 | SQLite snapshot; age-encrypted when =[backup] age_recipients= is set | |
| 53 | 53 | | Offsite (restic)| nightly | per prune policy | =/var/lib/gitbay= and a staged database copy, to object storage | |
| 54 | 54 | |
| 55 | 55 | - The database snapshot is taken before repositories are read, so a |
.gitbay/wiki/Architecture/09-Controls.org
+1 −1
| @@ -58,7 +58,7 @@ chapter names of OWASP ASVS 4.0 where one fits. |
| 58 | 58 | | TLS for all authenticated HTTP | in place | ACME or certificate files; HSTS | |
| 59 | 59 | | Secrets encrypted at rest | in place | AES-256-GCM, key file outside the database and the main backups (=internal/seal=) | |
| 60 | 60 | | Secrets kept out of argv, logs and output | in place | =ReadsStdin=, pruned audit argv, write-only secret commands | |
| 61 | | | Local backups encrypted | gap | tar.gz in clear; offsite copy encrypted by restic (#274) | |
| 61 | | Local backups encrypted | in place | age to =[backup] age_recipients= (=cmd/gitbayd/backup.go=); offsite copy by restic | |
| 62 | 62 | | Data retention configurable | in place | =[retention]= (=internal/config/config.go=) | |
| 63 | 63 | | User data export | in place | =account export= | |
| 64 | 64 | |
.gitbay/wiki/Architecture/10-Known-Gaps.org
+1 −2
| @@ -14,8 +14,7 @@ what the 2026-09-27 review found; remove a row when its issue closes. |
| 14 | 14 | | #260 | CI network | Builds share the runner's source address; no egress policy | medium | |
| 15 | 15 | | #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | |
| 16 | 16 | | #262 | Availability | No limit on concurrent git pack generation | high | |
| 17 | | | #274 | Backups | The local backup archive is not encrypted | medium | |
| 18 | | | #298 | SSRF | =repo import --from= fetches without an address check | medium | |
| 17 | | #298 | SSRF | =repo import --from= fetches without an address check | medium | |
| 19 | 18 | | #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | |
| 20 | 19 | |
| 21 | 20 | * Not filed |
CHANGELOG.org
+3
| @@ -161,6 +161,9 @@ secret. |
| 161 | 161 | older server refuses the runner's =--step= and =--reason= (exit 2), |
| 162 | 162 | and its failed builds stay running until the reaper fails them. |
| 163 | 163 | (#266) |
| 164 | - =gitbayd admin backup= encrypts archives to =[backup] age_recipients= |
| 165 | when set (#274); =--verify= takes =--identity <file>=. Archive names |
| 166 | gain =.age=; the shipped backup scripts and monitor match both. |
| 164 | 167 | |
| 165 | 168 | * v1.36.0 — 2026-09-23 |
| 166 | 169 | |
deploy/cloud-init.yaml
+5 −5
| @@ -96,7 +96,7 @@ write_files: |
| 96 | 96 | # archive and the newest database snapshot, in hours. |
| 97 | 97 | now=$(date -u +%s) |
| 98 | 98 | age_h() { |
| 99 | | f=$(ls -t "$1"/*.tar.gz 2>/dev/null | head -1) |
| 99 | f=$(ls -t "$1"/*.tar.gz "$1"/*.tar.gz.age 2>/dev/null | head -1) |
| 100 | 100 | [ -n "$f" ] || { echo ""; return; } |
| 101 | 101 | echo $(( (now - $(stat -c %Y "$f")) / 3600 )) |
| 102 | 102 | } |
| @@ -251,12 +251,12 @@ write_files: |
| 251 | 251 | # Nightly consistent backup; keeps the last 7 locally. |
| 252 | 252 | # To ship offsite, add an rclone/s3 upload of $out here. |
| 253 | 253 | set -eu |
| 254 | | # The archive carries the database, so it gets the database's mode. |
| 254 | # gitbayd writes the archive 0600, owned by the backup user. |
| 255 | 255 | umask 027 |
| 256 | 256 | dir=/var/backups/gitbay |
| 257 | 257 | out="$dir/gitbay-$(date -u +%Y%m%d-%H%M%S).tar.gz" |
| 258 | 258 | /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin backup --out "$out" |
| 259 | | ls -1t "$dir"/gitbay-*.tar.gz | tail -n +8 | xargs -r rm -- |
| 259 | ls -1t "$dir"/gitbay-*.tar.gz* | tail -n +8 | xargs -r rm -- |
| 260 | 260 | |
| 261 | 261 | # Hourly database-only snapshot. The nightly full backup below is the one |
| 262 | 262 | # that can rebuild the host; this one exists because the database holds |
| @@ -267,14 +267,14 @@ write_files: |
| 267 | 267 | content: | |
| 268 | 268 | #!/bin/sh |
| 269 | 269 | set -eu |
| 270 | | # The archive is the whole database, so it gets the database's mode. |
| 270 | # gitbayd writes the archive 0600, owned by the backup user. |
| 271 | 271 | umask 027 |
| 272 | 272 | dir=/var/backups/gitbay/db |
| 273 | 273 | mkdir -p "$dir" |
| 274 | 274 | chmod 0750 "$dir" |
| 275 | 275 | out="$dir/gitbay-db-$(date -u +%Y%m%d-%H%M%S).tar.gz" |
| 276 | 276 | /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin backup --db-only --out "$out" |
| 277 | | ls -1t "$dir"/gitbay-db-*.tar.gz | tail -n +49 | xargs -r rm -- |
| 277 | ls -1t "$dir"/gitbay-db-*.tar.gz* | tail -n +49 | xargs -r rm -- |
| 278 | 278 | |
| 279 | 279 | - path: /etc/systemd/system/gitbay-db-backup.service |
| 280 | 280 | content: | |