|
| 1 | +# Database backups and restore |
| 2 | + |
| 3 | +How the Makeability Lab website's data is backed up, how to check that it's |
| 4 | +actually happening, and how to restore it. See issue |
| 5 | +[#1443](https://github.com/makeabilitylab/makeabilitylabwebsite/issues/1443). |
| 6 | + |
| 7 | +**Read the [Restoring](#restoring) section before you need it.** The one step |
| 8 | +people get wrong under pressure is documented there: Postgres will not |
| 9 | +initialize into a non-empty data directory, and the dumps live *inside* that |
| 10 | +directory. |
| 11 | + |
| 12 | +## What is backed up, and by whom |
| 13 | + |
| 14 | +| Data | Where it lives | How it's protected | |
| 15 | +| --- | --- | --- | |
| 16 | +| Uploaded media (PDFs, images) | `/cse/web/research/makelab/www[-test]/` on the shared CSE filesystem | CSE IT's standard snapshot schedule — hourly, weekly, monthly, plus off-site to UW's lolo service. Retained 1 year. Plain files, so a snapshot is always consistent. | |
| 17 | +| Code and schema | git | GitHub | |
| 18 | +| **Database contents** | the `db` container's named volume (`db-data` → `/var/lib/postgresql/data`) | Two tiers, below | |
| 19 | + |
| 20 | +The database is the only piece that needs special handling, because a |
| 21 | +filesystem-level snapshot of a **live** Postgres data directory is not |
| 22 | +guaranteed to be transaction-consistent. Restoring one behaves like recovering |
| 23 | +from a hard power cut — usually fine, occasionally not. |
| 24 | + |
| 25 | +So there are two tiers: |
| 26 | + |
| 27 | +| Tier | Cadence | Guarantee | |
| 28 | +| --- | --- | --- | |
| 29 | +| CSE IT's ZFS snapshot of the raw volume | hourly | *Probably* restorable. Postgres crash recovery is designed for exactly this case. | |
| 30 | +| Our `pg_dump`, written **into** that volume | daily | Guaranteed consistent restore point. | |
| 31 | + |
| 32 | +Because the dump lives inside the volume that gets snapshotted, every snapshot |
| 33 | +automatically carries a known-good dump. A snapshot from six months ago contains |
| 34 | +that day's dump, which is why in-volume retention only needs to be 14 days. |
| 35 | + |
| 36 | +> **The dump cadence is the guaranteed worst-case RPO, and more snapshots don't |
| 37 | +> improve it.** Every hourly snapshot taken between two dumps contains the *same* |
| 38 | +> dump. Hourly snapshots give more copies of one restore point, not more restore |
| 39 | +> points. |
| 40 | +
|
| 41 | +## How it works |
| 42 | + |
| 43 | +A `db-backup` sidecar service in `docker-compose.yml` runs |
| 44 | +[`scripts/pg_backup.sh`](../scripts/pg_backup.sh) once an hour. The script is a |
| 45 | +single pass: if today's dump doesn't exist yet it makes one, prunes anything past |
| 46 | +retention, and writes a status file. |
| 47 | + |
| 48 | +- **Dumps:** `/var/lib/postgresql/data/pg_backups/makeability-YYYY-MM-DD.sql.gz` |
| 49 | + (UTC date, mode 0600). |
| 50 | +- **Retention:** 14 days, but the newest dump is *never* pruned regardless of |
| 51 | + age — otherwise a backup that had been failing for longer than the retention |
| 52 | + window would end with pruning deleting the last good dump too. |
| 53 | +- **Status:** `status.json` on a small shared volume that the website container |
| 54 | + mounts read-only. |
| 55 | + |
| 56 | +Two things in the compose config are load-bearing and shouldn't be "simplified": |
| 57 | + |
| 58 | +1. `entrypoint` is overridden. Left alone, the postgres image's own entrypoint |
| 59 | + would try to start a second database server on that `PGDATA`. |
| 60 | +2. The scheduling loop lives in `docker-compose.yml`, not inside |
| 61 | + `pg_backup.sh`. `docker compose up -d` only recreates a container whose |
| 62 | + *config* changed, so a loop inside the bind-mounted script would keep running |
| 63 | + stale code after a deploy — and there's no shell access to restart it by hand. |
| 64 | + |
| 65 | +## Checking that backups are actually running |
| 66 | + |
| 67 | +Because the dumps sit in a Docker volume on a host nobody has a shell on — and |
| 68 | +inside a `PGDATA` the website container can't even traverse — the status file is |
| 69 | +the only way to observe this. Three places surface it: |
| 70 | + |
| 71 | +- **`/version.json`** — `backup_ok`, `last_backup_at`, `backup_age_hours`, |
| 72 | + `backup_count`. No auth needed; use this for any external check. |
| 73 | +- **Admin dashboard** — a superuser-only warning callout, shown *only* when |
| 74 | + backups are stale or failing. |
| 75 | +- **Admin → Data Health** — a panel with last success, age, size, how many |
| 76 | + dumps are retained, and the last error. |
| 77 | + |
| 78 | +"Stale" means the newest dump is more than 36 hours old (`BACKUP_STALE_AFTER_HOURS`). |
| 79 | +That's 1.5× the daily cadence, so one missed run doesn't cry wolf but a second |
| 80 | +consecutive one does. |
| 81 | + |
| 82 | +## Restoring |
| 83 | + |
| 84 | +### The gotcha, first |
| 85 | + |
| 86 | +The dumps live at `pg_backups/` **inside** the Postgres data directory. `initdb` |
| 87 | +refuses to initialize into a non-empty directory, so you cannot wipe the database |
| 88 | +and leave the backups sitting there. **Copy the dump out of the volume first.** |
| 89 | +This is pinned by a test in `scripts/test_backup_restore.sh` so the warning can't |
| 90 | +silently go stale. |
| 91 | + |
| 92 | +### Restoring locally (development, or verifying a dump) |
| 93 | + |
| 94 | +```bash |
| 95 | +# 1. Get the dump out of the volume and onto your machine. |
| 96 | +docker compose -f docker-compose-local-dev.yml cp \ |
| 97 | + db-backup:/var/lib/postgresql/data/pg_backups/makeability-2026-08-07.sql.gz . |
| 98 | + |
| 99 | +# 2. Stop the stack and destroy the database volume. |
| 100 | +docker compose -f docker-compose-local-dev.yml down |
| 101 | +docker volume rm makeabilitylabwebsite_postgres-data |
| 102 | + |
| 103 | +# 3. Bring just the database back up on a fresh, empty volume. |
| 104 | +docker compose -f docker-compose-local-dev.yml up -d db |
| 105 | + |
| 106 | +# 4. Restore. |
| 107 | +gunzip -c makeability-2026-08-07.sql.gz | \ |
| 108 | + docker compose -f docker-compose-local-dev.yml exec -T db \ |
| 109 | + psql -v ON_ERROR_STOP=1 -U admin -d makeability |
| 110 | + |
| 111 | +# 5. Start the site and confirm Django agrees the database is complete. |
| 112 | +docker compose -f docker-compose-local-dev.yml up -d |
| 113 | +docker compose -f docker-compose-local-dev.yml exec website python manage.py migrate --check |
| 114 | +``` |
| 115 | + |
| 116 | +Step 5 is the real test. `migrate --check` exits non-zero if Django thinks |
| 117 | +migrations are pending, which is how you'd catch a restore that brought back |
| 118 | +tables but not the `django_migrations` table. |
| 119 | + |
| 120 | +### Restoring production or test |
| 121 | + |
| 122 | +**This requires someone with Docker access on the host** — `grabthar` for |
| 123 | +production, `docker-test2` for test. The maintainer does not have that (see the |
| 124 | +server access model in `CLAUDE.md`), so a production restore means opening a |
| 125 | +ticket with UW CSE IT. Send them this section. |
| 126 | + |
| 127 | +The steps are the same as above, against `docker-compose.yml` and the external |
| 128 | +volume `makeabilitylabcswashingtonedu_postgres16-data`. Before destroying |
| 129 | +anything: |
| 130 | + |
| 131 | +1. **Copy the chosen dump somewhere off the volume first.** If the volume itself |
| 132 | + is the problem, ask CSE IT to recover the dump from a ZFS snapshot or from |
| 133 | + lolo instead — the dump inside a snapshot is exactly what this whole scheme |
| 134 | + exists to provide. |
| 135 | +2. **Take a copy of the current broken volume before overwriting it.** A |
| 136 | + corrupt database still contains data; a hasty restore over the top of it |
| 137 | + destroys any chance of salvaging rows the dump predates. |
| 138 | +3. Restore, then confirm via `/version.json` and by loading the site. |
| 139 | + |
| 140 | +There's no ad-hoc "back up right now" button, deliberately — see the follow-up |
| 141 | +note in #1443. If you need a fresh dump before something risky and you can't |
| 142 | +reach the host, the practical options are to wait for the next pass or ask CSE |
| 143 | +IT to run one. |
| 144 | + |
| 145 | +## Testing the backups |
| 146 | + |
| 147 | +Two harnesses, both self-contained and namespaced so they never touch your real |
| 148 | +stack, database, or volumes. **An untested backup is not a backup** — run these |
| 149 | +after any change to `pg_backup.sh` or the compose wiring. |
| 150 | + |
| 151 | +```bash |
| 152 | +# Mechanics: dump → destroy → restore, on a synthetic schema built to break a |
| 153 | +# naive dump (unicode, embedded quotes and newlines, NULLs, binary columns, |
| 154 | +# a 540 KB row, views, foreign keys, sequences). ~1 minute. |
| 155 | +bash scripts/test_backup_restore.sh |
| 156 | + |
| 157 | +# The real thing: builds the actual Makeability Lab schema with `migrate`, seeds |
| 158 | +# through the ORM (sortedm2m through-tables, rich text), backs up, destroys the |
| 159 | +# volume, restores, and asserts Django accepts the result — `migrate --check` |
| 160 | +# and `manage.py check` both pass. Needs a built website image. ~3 minutes. |
| 161 | +bash scripts/test_backup_restore_django.sh |
| 162 | +``` |
| 163 | + |
| 164 | +The Django-side unit tests (status file parsing, staleness, failure reporting) |
| 165 | +run in the normal suite: |
| 166 | + |
| 167 | +```bash |
| 168 | +python manage.py test website.tests.test_backup_status --settings=makeabilitylab.settings_test |
| 169 | +``` |
| 170 | + |
| 171 | +## Security |
| 172 | + |
| 173 | +The dumps contain personal data — `Person.email`, which is deliberately withheld |
| 174 | +from the public API. They are written mode 0600 into a Docker volume. |
| 175 | + |
| 176 | +**Never move a dump under `media/`, `static/`, or any other web-served path.** |
| 177 | +Everything under those is publicly downloadable. The status file is safe to |
| 178 | +surface in the admin because it carries no row data. |
| 179 | + |
| 180 | +## Configuration |
| 181 | + |
| 182 | +Set on the `db-backup` service in `docker-compose.yml`: |
| 183 | + |
| 184 | +| Variable | Default | Meaning | |
| 185 | +| --- | --- | --- | |
| 186 | +| `BACKUP_RETENTION_DAYS` | 14 | Delete dumps older than this (never the newest). | |
| 187 | +| `BACKUP_MIN_KEEP` | 1 | Dumps always kept regardless of age. | |
| 188 | +| `BACKUP_POLL_SECONDS` | 3600 | Time between passes. | |
| 189 | +| `BACKUP_RETRY_SECONDS` | 300 | Time between passes after a failure. | |
| 190 | + |
| 191 | +Django-side, in `settings.py`: |
| 192 | + |
| 193 | +| Setting | Default | Meaning | |
| 194 | +| --- | --- | --- | |
| 195 | +| `BACKUP_STATUS_FILE` | `/var/backup-status/status.json` | Where to read status from (`ML_BACKUP_STATUS_FILE`). | |
| 196 | +| `BACKUP_STALE_AFTER_HOURS` | 36 | Age at which the dashboard warns (`ML_BACKUP_STALE_AFTER_HOURS`). | |
0 commit comments