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
179179pushes) is capped the same way the mail queue is, by =[retention]
180180push=.
181181
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
182191** [api]
183192- =enabled= (false) — the JSON API surface; see [[API]]. Off
184193 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
442451archive. A database-only archive is checked for integrity and says so.
443452Exit is non-zero on damage or a missing repository.
444453
454With =[backup] age_recipients= set the archive is =<name>.tar.gz.age=
455and =--verify= needs the private key:
456
457#+begin_src sh
458gitbayd admin backup --verify gitbay-20260927-090000.tar.gz.age --identity ~/.config/gitbay/backup-identity.txt
459age -d -i ~/.config/gitbay/backup-identity.txt gitbay-20260927-090000.tar.gz.age | tar -xz -C /new/root
460#+end_src
461
462The identity lives off the host (with the secret key file and the
463restic credentials), so verifying an encrypted archive happens there
464or on a restore host.
465
445466Restore: extract into an empty directory, point =server.root= at it,
446467start gitbayd. Host keys are preserved, so clients keep their
447468known_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=).
4545| 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=) |
4646| 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=) |
4747| 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 |
4949| Disk | no application-level encryption; any disk encryption is the host's |
5050
5151The 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.
4848
4949| Item | Schedule | Kept | Contents |
5050|-----------------+----------+------+-----------------------------------------------------------------|
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 |
5353| Offsite (restic)| nightly | per prune policy | =/var/lib/gitbay= and a staged database copy, to object storage |
5454
5555- 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.
5858| TLS for all authenticated HTTP | in place | ACME or certificate files; HSTS |
5959| Secrets encrypted at rest | in place | AES-256-GCM, key file outside the database and the main backups (=internal/seal=) |
6060| 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 |
6262| Data retention configurable | in place | =[retention]= (=internal/config/config.go=) |
6363| User data export | in place | =account export= |
6464
.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.
1414| #260 | CI network | Builds share the runner's source address; no egress policy | medium |
1515| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
1616| #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 |
1918| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
2019
2120* Not filed
CHANGELOG.org +3
@@ -161,6 +161,9 @@ secret.
161161 older server refuses the runner's =--step= and =--reason= (exit 2),
162162 and its failed builds stay running until the reaper fails them.
163163 (#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.
164167
165168* v1.36.0 — 2026-09-23
166169
deploy/cloud-init.yaml +5 −5
@@ -96,7 +96,7 @@ write_files:
9696 # archive and the newest database snapshot, in hours.
9797 now=$(date -u +%s)
9898 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)
100100 [ -n "$f" ] || { echo ""; return; }
101101 echo $(( (now - $(stat -c %Y "$f")) / 3600 ))
102102 }
@@ -251,12 +251,12 @@ write_files:
251251 # Nightly consistent backup; keeps the last 7 locally.
252252 # To ship offsite, add an rclone/s3 upload of $out here.
253253 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.
255255 umask 027
256256 dir=/var/backups/gitbay
257257 out="$dir/gitbay-$(date -u +%Y%m%d-%H%M%S).tar.gz"
258258 /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 --
260260
261261 # Hourly database-only snapshot. The nightly full backup below is the one
262262 # that can rebuild the host; this one exists because the database holds
@@ -267,14 +267,14 @@ write_files:
267267 content: |
268268 #!/bin/sh
269269 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.
271271 umask 027
272272 dir=/var/backups/gitbay/db
273273 mkdir -p "$dir"
274274 chmod 0750 "$dir"
275275 out="$dir/gitbay-db-$(date -u +%Y%m%d-%H%M%S).tar.gz"
276276 /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 --
278278
279279 - path: /etc/systemd/system/gitbay-db-backup.service
280280 content: |