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
179pushes) is capped the same way the mail queue is, by =[retention] 179pushes) is capped the same way the mail queue is, by =[retention]
180push=. 180push=.
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** [api] 191** [api]
183- =enabled= (false) — the JSON API surface; see [[API]]. Off 192- =enabled= (false) — the JSON API surface; see [[API]]. Off
184 means no credential-bearing HTTP endpoint exists at all. 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
442archive. A database-only archive is checked for integrity and says so. 451archive. A database-only archive is checked for integrity and says so.
443Exit is non-zero on damage or a missing repository. 452Exit is non-zero on damage or a missing repository.
444 453
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
445Restore: extract into an empty directory, point =server.root= at it, 466Restore: extract into an empty directory, point =server.root= at it,
446start gitbayd. Host keys are preserved, so clients keep their 467start gitbayd. Host keys are preserved, so clients keep their
447known_hosts entries; hooks regenerate at startup. 468known_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| 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=) | 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| 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=) | 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| SQLite file | mode 0640, directory 0750 | 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| Disk | no application-level encryption; any disk encryption is the host's | 49| Disk | no application-level encryption; any disk encryption is the host's |
50 50
51The database file or a backup read by anyone other than the =gitbay= 51The 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| Item | Schedule | Kept | Contents | 49| Item | Schedule | Kept | Contents |
50|-----------------+----------+------+-----------------------------------------------------------------| 50|-----------------+----------+------+-----------------------------------------------------------------|
51| Full archive | nightly | 7 | SQLite snapshot (=VACUUM INTO=), all repositories, LFS, SSH host keys | 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 | 52| Database only | hourly | 48 | SQLite snapshot; age-encrypted when =[backup] age_recipients= is set |
53| Offsite (restic)| nightly | per prune policy | =/var/lib/gitbay= and a staged database copy, to object storage | 53| Offsite (restic)| nightly | per prune policy | =/var/lib/gitbay= and a staged database copy, to object storage |
54 54
55- The database snapshot is taken before repositories are read, so a 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| TLS for all authenticated HTTP | in place | ACME or certificate files; HSTS | 58| TLS for all authenticated HTTP | in place | ACME or certificate files; HSTS |
59| Secrets encrypted at rest | in place | AES-256-GCM, key file outside the database and the main backups (=internal/seal=) | 59| Secrets encrypted at rest | in place | AES-256-GCM, key file outside the database and the main backups (=internal/seal=) |
60| Secrets kept out of argv, logs and output | in place | =ReadsStdin=, pruned audit argv, write-only secret commands | 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| Data retention configurable | in place | =[retention]= (=internal/config/config.go=) | 62| Data retention configurable | in place | =[retention]= (=internal/config/config.go=) |
63| User data export | in place | =account export= | 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| #260 | CI network | Builds share the runner's source address; no egress policy | medium | 14| #260 | CI network | Builds share the runner's source address; no egress policy | medium |
15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | 15| #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium |
16| #262 | Availability | No limit on concurrent git pack generation | high | 16| #262 | Availability | No limit on concurrent git pack generation | high |
17| #274 | Backups | The local backup archive is not encrypted | medium | 17| #298 | SSRF | =repo import --from= fetches without an address check | medium |
18| #298 | SSRF | =repo import --from= fetches without an address check | medium |
19| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | 18| #297 | Credentials | A browser session can mint tokens and keys that outlive it | low |
20 19
21* Not filed 20* Not filed
CHANGELOG.org +3
@@ -161,6 +161,9 @@ secret.
161 older server refuses the runner's =--step= and =--reason= (exit 2), 161 older server refuses the runner's =--step= and =--reason= (exit 2),
162 and its failed builds stay running until the reaper fails them. 162 and its failed builds stay running until the reaper fails them.
163 (#266) 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* v1.36.0 — 2026-09-23 168* v1.36.0 — 2026-09-23
166 169
deploy/cloud-init.yaml +5 −5
@@ -96,7 +96,7 @@ write_files:
96 # archive and the newest database snapshot, in hours. 96 # archive and the newest database snapshot, in hours.
97 now=$(date -u +%s) 97 now=$(date -u +%s)
98 age_h() { 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 [ -n "$f" ] || { echo ""; return; } 100 [ -n "$f" ] || { echo ""; return; }
101 echo $(( (now - $(stat -c %Y "$f")) / 3600 )) 101 echo $(( (now - $(stat -c %Y "$f")) / 3600 ))
102 } 102 }
@@ -251,12 +251,12 @@ write_files:
251 # Nightly consistent backup; keeps the last 7 locally. 251 # Nightly consistent backup; keeps the last 7 locally.
252 # To ship offsite, add an rclone/s3 upload of $out here. 252 # To ship offsite, add an rclone/s3 upload of $out here.
253 set -eu 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 umask 027 255 umask 027
256 dir=/var/backups/gitbay 256 dir=/var/backups/gitbay
257 out="$dir/gitbay-$(date -u +%Y%m%d-%H%M%S).tar.gz" 257 out="$dir/gitbay-$(date -u +%Y%m%d-%H%M%S).tar.gz"
258 /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin backup --out "$out" 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 # Hourly database-only snapshot. The nightly full backup below is the one 261 # Hourly database-only snapshot. The nightly full backup below is the one
262 # that can rebuild the host; this one exists because the database holds 262 # that can rebuild the host; this one exists because the database holds
@@ -267,14 +267,14 @@ write_files:
267 content: | 267 content: |
268 #!/bin/sh 268 #!/bin/sh
269 set -eu 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 umask 027 271 umask 027
272 dir=/var/backups/gitbay/db 272 dir=/var/backups/gitbay/db
273 mkdir -p "$dir" 273 mkdir -p "$dir"
274 chmod 0750 "$dir" 274 chmod 0750 "$dir"
275 out="$dir/gitbay-db-$(date -u +%Y%m%d-%H%M%S).tar.gz" 275 out="$dir/gitbay-db-$(date -u +%Y%m%d-%H%M%S).tar.gz"
276 /usr/local/bin/gitbayd --config /etc/gitbay/config.toml admin backup --db-only --out "$out" 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 - path: /etc/systemd/system/gitbay-db-backup.service 279 - path: /etc/systemd/system/gitbay-db-backup.service
280 content: | 280 content: |