forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
19.9 KiB
1# Backups
2
3This is the operator guide for forge backups. The design is in `docs/architecture.md` §8. Read this page before you rely on backups, and run the restore drill below at least once a quarter.
4
5## What a backup set contains
6
7A set is a directory `forge-backup-<UTC stamp>/` with:
8
9| Path | Contents |
10| --- | --- |
11| `manifest.json` | Format version, forge version, creation time, base URL, the sha256 and size of every file, and for every repo its id, owner, name, `refs_sha256` and bundle path (or `null` for an empty repo). |
12| `db/forge.db` | A consistent copy of the metadata database (users, issues, pull requests, settings, audit log, and so on), made with `VACUUM INTO`. |
13| `repos/<repo id>.bundle` | `git bundle create --all` for every non-empty repo. Each bundle is imported and checked before the set is finished. |
14| `keys/forge.key` | Only with `--include-key`. See below. |
15
16What a set does **not** contain:
17
18- **The instance key `forge.key`, unless you ask for it.** The key encrypts sealed values: 2FA secrets, webhook secrets and similar. A set without the key restores the data, but those sealed values stay unreadable. Without the key they are lost for good. Escrow the key separately, offline, for example in a password manager and on paper in a safe. Do not put it on the same off-site storage as the sets unless that storage is encrypted with a key you hold elsewhere.
19- Server configuration (`config/forge.toml`), SSH host keys, the managed gitconfig and hooks. These are rewritten on boot, and the restore writes them again.
20- Attachments and avatars that live outside the repos, if your deployment stores any. Check `data_dir` before you trust a restore is complete.
21- **Release assets under `data_dir/blobs/`.** A set does not copy them. Back them up with the separate `rsync` step in "Release assets" below.
22- **Git LFS objects under `data_dir/lfs/`.** A set does not copy them either. The `lfs_objects` rows are in the set, so a restore without the files gives LFS pointers that cannot be downloaded. Back them up with the `rsync` step in "LFS objects" below.
23
24Files in a set are mode 0600 and directories are mode 0700. Treat every set as private data, because it contains the full contents of every repo and the password hashes of every user.
25
26A set is written under a `.partial-…` name and renamed into place only when it is complete. A crash never leaves something that looks like a finished backup.
27
28## Making a backup
29
30```sh
31forge --config /etc/forge/forge.toml admin backup --out /var/backups/forge
32```
33
34Options:
35
36- `--include-key`: also copy `forge.key` into the set. Use this only when the set is stored where the key may live.
37- `--repos-only` or `--db-only`: a partial set. These cannot be restored with `admin restore`. They exist for special cases such as shipping just the bundles.
38
39The command prints the path of the set and a summary. It exits non-zero on any failure, and then nothing is left behind.
40
41Automatic backups are configured in the `[backup]` section of `forge.toml`. The scheduler runs inside the server and writes the same kind of set. Its status and the last 20 runs are on `/admin/backups`:
42
43```toml
44[backup]
45dir = "/var/backups/forge" # empty = disabled
46interval_hours = 24
47keep = 7 # keeps the newest 7 complete sets; never touches other files
48healthcheck_url = "" # optional, healthchecks.io style
49fsck_interval_days = 7
50```
51
52> The `admin backup` command opens the metadata database from a second process. Turso does not support several processes using one database file at the same time, so run the CLI form with the server **stopped**. While the server is running, use the scheduler or "Run backup now" on `/admin/backups`.
53
54## Checking a set
55
56```sh
57forge admin backup-verify /var/backups/forge/forge-backup-20261015T031500Z
58```
59
60This recomputes every sha256 and size, checks that no unlisted file is present, imports every bundle into a scratch repo and compares its refs with the manifest, runs `integrity_check` on the database copy, and compares the repo list with the database. It prints every problem and exits non-zero if there is any. Scratch space is created next to the set, not in `/tmp`.
61
62Run it on every set you ship off-site, and after every copy you make. A set that has not been verified is not a backup.
63
64## Shipping off-site
65
66Keep at least one copy at a second provider and, ideally, one copy that the server cannot delete (3-2-1-1-0 from `docs/architecture.md` §8.4). The server should hold no delete credentials for the backup storage.
67
68### Option A: restic with an append-only rest-server
69
70On the backup host, run rest-server in append-only mode. A client can add snapshots but cannot delete or rewrite them:
71
72```sh
73rest-server --path /srv/restic --private-repos --append-only --listen 127.0.0.1:8000
74```
75
76Put it behind TLS (a reverse proxy) and give the forge host a password that only has access to its own repo. On the forge host:
77
78```sh
79export RESTIC_REPOSITORY='rest:https://forge-backup:PASSWORD@backup.example.invalid/forge'
80export RESTIC_PASSWORD_FILE=/etc/forge/restic-password # 0600, root or the forge user only
81restic init # once
82restic backup /var/backups/forge
83restic check --read-data-subset=1/20 # spot-check a slice of the data
84```
85
86Append-only means `restic forget` and `restic prune` fail on the forge host. Do retention from a separate machine that holds the full credentials, which should not be the forge server. Keep a second restic key (`restic key add`) and store its password offline.
87
88### Option B: rclone to object storage with object lock
89
90Use an `rclone` remote whose bucket has object lock in compliance mode (Backblaze B2, Cloudflare R2 bucket locks, or Wasabi compliance mode). Use `copy`, never `sync`: `sync` deletes remote files that are not present locally, which turns the server's own pruning into remote deletion.
91
92```sh
93rclone copy /var/backups/forge remote:forge-backups --immutable --checksum
94rclone check /var/backups/forge remote:forge-backups --checksum
95```
96
97`--immutable` stops rclone from overwriting or deleting existing remote files. Object lock is what makes the copies permanent, so set the retention period on the bucket and not only in rclone.
98
99### Scheduling with systemd
100
101The job is a small script, so that the off-site upload runs only after the new set has verified. `/usr/local/bin/forge-backup-run`:
102
103```sh
104#!/bin/sh
105set -eu
106config=/etc/forge/forge.toml
107out=/var/backups/forge
108forge --config "$config" admin backup --out "$out"
109# Stamps sort lexically, so the last name is the newest complete set. Partial sets are named .partial-… and do not match.
110set_dir=$(ls -1d "$out"/forge-backup-* | sort | tail -n 1)
111forge --config "$config" admin backup-verify "$set_dir"
112restic backup "$out"
113```
114
115`/etc/systemd/system/forge-backup.service`:
116
117```ini
118[Unit]
119Description=Forge backup set and off-site copy
120OnFailure=forge-backup-failed.service
121
122[Service]
123Type=oneshot
124User=forge
125Group=forge
126ExecStart=/usr/local/bin/forge-backup-run
127```
128
129`/etc/systemd/system/forge-backup.timer`:
130
131```ini
132[Unit]
133Description=Nightly forge backup
134
135[Timer]
136OnCalendar=*-*-* 03:15:00
137Persistent=true
138RandomizedDelaySec=10min
139
140[Install]
141WantedBy=timers.target
142```
143
144Enable it with `systemctl enable --now forge-backup.timer`. The `OnFailure=` unit should ping your alerting, or a healthchecks.io `/fail` URL. A missed success ping must alert too, so use a dead-man switch: the job pings `/start`, then the plain URL on success.
145
146## Restoring
147
148Restoring is for a fresh machine or a fresh directory. The restore refuses a target that is not empty, and it refuses a set that fails verification. It never deletes anything.
149
1501. **Get the set.** Copy the newest verified set from off-site storage to the restore host, for example with `restic restore latest --target /restore` or `rclone copy`.
1512. **Verify it.**
152 ```sh
153 forge admin backup-verify /restore/forge-backup-20261015T031500Z
154 ```
155 Stop here if the result is not `OK`.
1563. **Get the key.** Place your escrowed `forge.key` where you will restore it. You do not have to place it before the restore: the restore can copy it from the set if the set has one. If the set has no key, you must copy the key in by hand before the server starts (step 6).
1574. **Restore into an empty directory.**
158 ```sh
159 forge admin restore --from /restore/forge-backup-20261015T031500Z --data-dir /var/lib/forge
160 ```
161 The command verifies the set again, copies the database to `/var/lib/forge/forge.db`, recreates each repo with `init --bare`, fetches its bundle with `+refs/*:refs/*`, sets HEAD to the repo's default branch, and checks each repo's refs against the manifest. It also writes the managed gitconfig and hooks, and runs `git fsck --full` on each repo. It prints `WARNING:` lines for anything you must act on.
1625. **Point the config at the restored data.** Set `data_dir` and `database` in `forge.toml` to the values the restore printed, for example `database = "/var/lib/forge/forge.db"`. The database path is independent of `data_dir` in the config, so it must be set explicitly.
1636. **If the restore printed `NO INSTANCE KEY WAS RESTORED`:** copy the escrowed key to `/var/lib/forge/forge.key` with mode 0600, owned by the forge user. Do this before starting the server. If the server starts without the key, it creates a new one, and the sealed data stays unreadable.
1647. **Run the integrity check.**
165 ```sh
166 forge --config /etc/forge/forge.toml admin fsck --full
167 ```
168 It runs `git fsck` on every repo and `integrity_check` on the database, and exits non-zero on any problem.
1698. **Start the server** and check: you can log in, the issues and pull requests are there, a clone over HTTP and SSH works, and a push works.
1709. **Record the time.** The time from the start of step 1 to the end of step 8 is your real recovery time. Write it down.
171
172If the restore fails, the target directory is left as it was when the failure happened. Do not start the server from it. Read the error, fix the cause (for example, free disk space or the git version), and restore into a new empty directory. Do not delete the failed directory until someone has looked at it.
173
174## Quarterly restore drill
175
176Do this on a machine that is not the forge server, once every three months. Keep the answers in your ops log.
177
178- [ ] Pick the newest set in each off-site location, and the set from about 90 days ago.
179- [ ] Download each one. Record the download time and size.
180- [ ] Run `admin backup-verify` on each. Record the result. Any problem is an incident.
181- [ ] Restore the newest set into an empty directory with `admin restore`. Time it.
182- [ ] Copy the escrowed `forge.key` in by hand, from the offline copy, not from the set. Confirm the key matches the one in use: a sealed value (for example, a 2FA login) must decrypt.
183- [ ] Run `admin fsck --full`. Expect no problems.
184- [ ] Start a throwaway server on the restored data. Log in as an admin, open one issue, clone one repo over HTTP, clone one over SSH.
185- [ ] Check that the sets you have are still readable with the keys you hold offline (restic: `restic snapshots`, and `restic check` from the second key).
186- [ ] Confirm that the monitoring for the backup jobs and the fsck job still reports to you.
187- [ ] Update the recovery time and the date of the last drill in your ops log. If the measured time is longer than your target, fix the cause before the next drill.
188
189## Failover fencing
190
191Restoring a backup on another machine does not by itself make that machine the only writer. Before you bring up a restored standby as the primary, follow `docs/architecture.md` §8.5:
192
1931. Stop writes on the old primary (block the HTTP and SSH listeners, or put the instance in read-only mode).
1942. Fence the old primary: revoke its replication and backup credentials, and make sure it cannot accept writes. Do this before promotion. A DNS change or a health check does not stop the old primary from coming back as a second writer.
1953. Confirm the last snapshot you restored and the last ref reconcile. Record the data-loss window.
1964. Restore on the standby, then run `quick_check` and `fsck`.
1975. Promote the standby and switch DNS.
1986. Record the incident and the actual recovery time.
199
200## Standby replication
201
202A standby is a passive SSH host at another provider. It holds bare copies of every repo and a
203rolling set of database snapshots. It does not run forge. Replication is off unless `[replication]
204target` is set.
205
206### What the standby receives
207
208- Every repo, as a bare repo at `<base>/repos/<repo id>.git`. A push is sent after each ref update,
209 debounced by 5 seconds. Every `reconcile_minutes` (default 60) every repo is pushed again, and the
210 refs checksum of the standby copy is compared with the local one. A mismatch triggers one re-push.
211 A second mismatch is recorded as an error.
212- A database snapshot every `db_snapshot_minutes` (default 15). It is taken with `VACUUM INTO`,
213 checked with `PRAGMA integrity_check`, uploaded to `<base>/db/forge-<UTC stamp>.db` through a
214 temporary name, and then `<base>/db/latest` is pointed at it. The standby keeps the newest 24.
215- Deleted repos: at the next reconcile, the standby copy of exactly that repo is removed.
216
217Nothing else is written. `<base>/keys/` belongs to the operator. Forge never writes to it, and the
218instance key should be escrowed there by hand, as described under "Restoring".
219
220Status is on `/admin/replication` (admin only): the target, the last database snapshot, and one row
221per repo (ok, lagging, error, or never replicated). "Reconcile now" runs a full pass at once. It is
222audited as `replication_reconcile_started`. If `[replication] healthcheck_url` is set, it is pinged
223after each reconcile: success when every step worked, `/fail` with the errors otherwise.
224
225### Setting up the standby account
226
2271. On the standby host, create a dedicated unix account with no password and a login shell that can
228 run commands (`/bin/sh`). Create `<base>` owned by that account, mode 0700. Git must be installed,
229 version 2.38 or newer.
2302. On the forge host, generate a key used only for replication, with no passphrase:
231 `ssh-keygen -t ed25519 -N '' -f /etc/forge/standby_key`, and set it to mode 0600, owned by the
232 forge user.
2333. On the standby, add the public key to `~<account>/.ssh/authorized_keys` with the `restrict` option
234 and nothing that grants more:
235
236 ```
237 restrict ssh-ed25519 AAAA...rest-of-public-key forge-replication
238 ```
239
240 `restrict` turns off port forwarding, agent forwarding, X11 forwarding and the pty. It does not
241 force a command, because replication runs `git receive-pack`, `git for-each-ref`, `cat`, `ls`,
242 `rm` and `mkdir` through ssh. The account's file permissions are what keep it inside `<base>`.
243 Keep `<base>` the only directory the account can write to, and do not give it sudo.
2444. Pin the standby's host key on the forge host. Get it over a channel you trust (the provider's
245 console, or a fingerprint you already hold), compare the fingerprint with the server's own
246 `ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`, and then write the key line to a file of its
247 own:
248
249 ```
250 ssh-keyscan -t ed25519 -p 22 standby.example > /etc/forge/standby_known_hosts
251 ```
252
253 Do not use `ssh-keyscan` without the fingerprint check. Forge always runs ssh with
254 `StrictHostKeyChecking=yes`, `UserKnownHostsFile` set to this file, and `IdentitiesOnly=yes`, so
255 a changed host key stops replication instead of being accepted. If the standby is rebuilt, repeat
256 this step and check the new fingerprint.
2575. Configure forge:
258
259 ```toml
260 [replication]
261 target = "forge-sync@standby.example:/srv/forge-standby"
262 ssh_port = 22
263 identity_file = "/etc/forge/standby_key"
264 known_hosts_file = "/etc/forge/standby_known_hosts"
265 db_snapshot_minutes = 15
266 reconcile_minutes = 60
267 healthcheck_url = ""
268 ```
269
270 The target is `[user@]host:/absolute/path`. The path may use only `A-Z a-z 0-9 . _ - /`, with no
271 `..`. Restart forge. The worker starts at boot and runs a snapshot and a reconcile straight away.
2726. Check `/admin/replication`. Every repo should show "ok" within a few minutes. On the standby,
273 `ls <base>/db` should show `latest` and at least one `forge-*.db`.
274
275### Failover runbook
276
277Follow `docs/architecture.md` §8.5. The steps below are specific to the standby layout.
278
2791. Stop writes on the primary, and fence it. Revoke its replication key (remove the line from the
280 standby's `authorized_keys`), and block its HTTP and SSH listeners. Do this before anything else.
281 Without fencing, the old primary can come back and accept pushes.
2822. Record the data-loss window: the time of `db/latest` (from its file name), and the last
283 successful reconcile on `/admin/replication`, if the primary still answers.
2843. On a new host with the same forge version, create the data directory, and copy the escrowed
285 `forge.key` into `<data_dir>/forge.key`. Without this key sealed values cannot be read.
2864. Put the snapshot in place at the path the config's `database` setting names:
287 `cp -L <base>/db/latest <database path>`. `-L` follows the `latest` link, so the copy is a plain file.
2885. Move each repo into the local layout. The local path is `<data_dir>/repos/<id[0..2]>/<id[2..4]>/<id>.git`,
289 where `<id>` is the lowercase repo id. For example:
290
291 ```sh
292 for repo in <base>/repos/*.git; do
293 id=$(basename "$repo" .git)
294 lower=$(printf '%s' "$id" | tr 'A-Z' 'a-z')
295 dest="<data_dir>/repos/$(printf '%s' "$lower" | cut -c1-2)/$(printf '%s' "$lower" | cut -c3-4)/$lower.git"
296 mkdir -p "$(dirname "$dest")" && cp -a "$repo" "$dest"
297 done
298 ```
299
3006. Run `forge admin fsck --full`. Expect no problems. Run `quick_check` on the database.
3017. Start forge on the new host, log in as an admin, and clone one repo over HTTP and one over SSH.
3028. Promote the new host and switch DNS. Record the incident and the actual recovery time.
303
304## Release assets
305
306Release asset bytes live in `<data_dir>/blobs/assets/<asset id>`, outside the database and the repos. The database rows that point at them are in every backup set, so a restore without the blobs leaves downloads returning 404. Keep the blobs in step with the sets:
307
308- Blobs are immutable. An asset is written once under its id and never rewritten, only added or deleted. The writer streams to a temporary file and renames it into place when complete, so a copy taken at any moment never holds half of an asset that is listed in the database.
309- Copy the directory with a plain `rsync -a --delete <data_dir>/blobs/ <backup target>/blobs/`. Do it after each backup set, or on the same schedule. Copying during an upload is safe: the in-flight file is a temporary name that rsync may skip or copy, and the row that references it is written only after the rename.
310- On restore, copy `blobs/` back into the new `data_dir` before starting the server. A row whose blob is missing is harmless to the server but its download returns 404; check the release pages after a drill.
311- Deleting a release or a repo deletes its blobs at once. The blob directory has no other index, so it can be checked against the database with a `SELECT id FROM release_assets` list.
312
313## LFS objects
314
315Git LFS object bytes live in `<data_dir>/lfs/<oid[0..2]>/<oid[2..4]>/<oid>`, outside the database and the repos. The `lfs_objects` rows that link them to repos are in every backup set. Copy the directory with the same `rsync` step as release blobs, after each backup set or on the same schedule:
316
317```sh
318rsync -a --delete <data_dir>/lfs/ <backup target>/lfs/
319```
320
321- **Restore:** copy `lfs/` back into the new `data_dir` before starting the server. Check one LFS file after a drill with `git lfs fetch` on a clone, or by opening a blob page that shows "Stored with Git LFS" and downloading it.
322- **Why the copy is safe:** objects are named by their sha256, so a name always holds the same bytes. An object is written to a temporary file, checked against its hash and size, fsynced, and then renamed into place. A copy taken at any moment sees either the whole object or nothing under its final name. Objects are never rewritten, only added, and removed by `admin lfs-gc`, which deletes only files with no row in any repo that are older than a day.