Skip to content

Commit 8a43c0f

Browse files
authored
Merge pull request #1444 from makeabilitylab/1443-nightly-db-backup
Nightly pg_dump into the postgres volume, with backup health reporting (#1443)
2 parents 6ecd33b + e035237 commit 8a43c0f

15 files changed

Lines changed: 1804 additions & 3 deletions

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ the existing viewset/serializer pattern and keep `v1` fields additive-only
132132
- `TIME_ZONE = 'America/Los_Angeles'`. `ML_WEBSITE_VERSION` in settings is shown in the admin header and used in release tagging.
133133
- **Logging (#1283):** `debug.log` lives at `LOG_DIR/debug.log`, where `LOG_DIR` is `$ML_LOG_DIR` or `<BASE_DIR>/media` (`/code/media` in the container). Keep it inside `MEDIA_ROOT` — that's the tree bind-mounted to the shared CSE filesystem, so it's what makes the log readable over SSH at all. `ML_LOG_DIR` is unset everywhere today; it exists for non-`/code` hosts. `MEDIA_ROOT` is web-served, so never log anything sensitive. If the dir isn't writable the file handler degrades to a `NullHandler` rather than crashing `django.setup()`, and since there's no console on the servers that state surfaces via `/version.json` (`log_to_file`) and a superuser-only callout on the admin dashboard. Rotation uses `concurrent-log-handler` (#1439) because Gunicorn's 3 workers share one file — the stdlib `RotatingFileHandler` races on rollover across processes. Its lock file goes in a per-uid temp dir (`/tmp/makelab-log-locks-<uid>`), never the web-served media root and never shared across users. If the package isn't importable (the bind-mounted checkout can be ahead of the image's site-packages) or no lock dir is usable, the handler degrades to the stdlib `RotatingFileHandler` instead of crashing `django.setup()`; `/version.json` reports which one is live as `log_rotation`. `django.db.backends` is pinned to INFO so per-query SQL doesn't dominate the log (or the lock).
134134

135+
- **Database backups (#1443):** a `db-backup` sidecar service (in *both* compose files) runs `scripts/pg_backup.sh` hourly; the script is a single pass that writes one dated `pg_dump | gzip` per UTC day to `pg_backups/` **inside the postgres data volume**, prunes past `BACKUP_RETENTION_DAYS` (14, never the newest dump), and writes `status.json` to a small volume the website container mounts read-only. Dumps go inside the data volume on purpose — that's the volume CSE IT snapshots, so every snapshot carries a consistent restore point. Two things are load-bearing: the sidecar's `entrypoint` **must** stay overridden (the postgres image's own entrypoint would start a second server on that `PGDATA`), and the scheduling loop lives in the compose file rather than in the script (`docker compose up -d` only recreates containers whose *config* changed, so a loop inside the bind-mounted script would run stale code forever after a deploy). Django only ever *reads* the status: `website/utils/backup_status.py` → `/version.json` (`backup_ok`), a superuser callout on the admin dashboard shown only when stale/failed, and a panel on Data Health. Dumps contain `Person.email` — never move one under a web-served path. Restore procedure, the `initdb`-refuses-a-non-empty-directory gotcha, and the two Docker-based restore harnesses (`scripts/test_backup_restore*.sh`): `docs/BACKUPS.md`.
136+
135137
### Container startup side effects (`docker-entrypoint.sh`)
136138

137139
Every container start runs, in order: `collectstatic``makemigrations``migrate``makemigrations website``migrate website``delete_unused_files``thumbnail_cleanup``generate_slugs_for_old_news_items``auto_close_project_roles``remove_year_from_forum_name``fix_sortedm2m_columns``seed_sidewalk_participants``warm_api_thumbnails``runserver 0.0.0.0:8000`. The repeated `makemigrations website` step is intentional (fixes first-run issues). If you add a one-shot data migration command under `website/management/commands/`, decide whether it belongs in this startup sequence.

docker-compose-local-dev.yml

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,66 @@ services:
7272
timeout: 5s
7373
retries: 5
7474

75+
# ===========================================================================
76+
# DATABASE BACKUP SERVICE (#1443)
77+
# ===========================================================================
78+
# Writes a dated, gzipped pg_dump into the postgres data volume once a day.
79+
#
80+
# On the servers this exists because volume-level snapshots of a *live*
81+
# postgres data directory are only probably restorable; the dump is the
82+
# guaranteed-consistent restore point. It runs locally too so that the same
83+
# code path is exercised in development rather than only in production.
84+
#
85+
# Two things here are load-bearing and should not be "simplified":
86+
# 1. `entrypoint` is overridden. Left alone, the postgres image's own
87+
# entrypoint would try to start a second database server on this PGDATA.
88+
# 2. The scheduling loop lives here, not inside pg_backup.sh, because
89+
# `docker compose up -d` only recreates containers whose config changed.
90+
# A loop inside the bind-mounted script would keep running stale code
91+
# after a deploy.
92+
#
93+
# To force a backup immediately instead of waiting for the next pass:
94+
# docker compose -f docker-compose-local-dev.yml exec db-backup \
95+
# bash /backup-scripts/pg_backup.sh
96+
#
97+
# To verify and restore, see docs/BACKUPS.md.
98+
db-backup:
99+
# Same image as `db` so the pg_dump binary version matches the server.
100+
image: 'postgres:16'
101+
restart: always
102+
103+
environment:
104+
# libpq connection settings; must match the `db` service above.
105+
- PGHOST=db
106+
- PGUSER=admin
107+
- PGPASSWORD=password
108+
- PGDATABASE=makeability
109+
# Delete dumps older than this, but never the most recent one.
110+
- BACKUP_RETENTION_DAYS=14
111+
# How long to wait between passes, and after a failed pass.
112+
- BACKUP_POLL_SECONDS=3600
113+
- BACKUP_RETRY_SECONDS=300
114+
115+
volumes:
116+
# The database volume, so dumps land inside the thing that gets snapshotted.
117+
- postgres-data:/var/lib/postgresql/data
118+
# Small shared volume for the status file Django reads.
119+
- backup-status:/var/backup-status
120+
# The backup script, mounted as a directory so edits survive git checkouts.
121+
- ./scripts:/backup-scripts:ro
122+
123+
# `sleep ... & wait $!` plus the leading `trap`, instead of a plain
124+
# foreground `sleep`, so `docker compose stop`/`down` return promptly:
125+
# bash only acts on a trapped signal between foreground commands, so a
126+
# SIGTERM arriving during a plain `sleep 3600` would sit unhandled for up
127+
# to an hour and this container would always eat the full stop grace
128+
# period before Docker escalates to SIGKILL.
129+
entrypoint: ["/bin/bash", "-c", "trap 'exit 0' TERM INT; while true; do if bash /backup-scripts/pg_backup.sh; then sleep \"$${BACKUP_POLL_SECONDS}\" & wait $!; else sleep \"$${BACKUP_RETRY_SECONDS}\" & wait $!; fi; done"]
130+
131+
depends_on:
132+
db:
133+
condition: service_healthy
134+
75135
# ===========================================================================
76136
# WEBSITE SERVICE (Django Application)
77137
# ===========================================================================
@@ -94,6 +154,9 @@ services:
94154
# This enables "live reloading"—when you edit local files, the changes
95155
# are immediately visible inside the container without rebuilding.
96156
- .:/code
157+
# Read-only view of the backup status file so Django can report backup
158+
# health on the admin dashboard and /version.json (#1443).
159+
- backup-status:/var/backup-status:ro
97160

98161
healthcheck:
99162
# Check if Django is responding
@@ -158,4 +221,6 @@ services:
158221
# Run 'docker volume ls' to see all volumes.
159222
# Run 'docker volume rm postgres-data' to delete (WARNING: destroys all data).
160223
volumes:
161-
postgres-data:
224+
postgres-data:
225+
# Disposable: holds only the backup status.json, regenerated every pass.
226+
backup-status:

docker-compose.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,45 @@ services:
1010
- POSTGRES_PASSWORD=password
1111
volumes:
1212
- db-data:/var/lib/postgresql/data
13+
# Consistent daily pg_dump into the postgres data volume (#1443). CSE IT's
14+
# ZFS snapshots capture the raw volume, which is only *probably* restorable
15+
# for a live database; this writes a guaranteed-consistent restore point
16+
# inside that same volume so every snapshot carries one. See docs/BACKUPS.md.
17+
#
18+
# Uses the same image as `db` so pg_dump's version always matches the server.
19+
# `entrypoint` MUST stay overridden: the postgres image's own entrypoint would
20+
# otherwise try to bring up a second server on this PGDATA.
21+
#
22+
# The scheduling loop lives here rather than inside pg_backup.sh on purpose —
23+
# `docker compose up -d` only recreates a container whose config changed, so a
24+
# loop inside the bind-mounted script would keep running the old code forever
25+
# after a deploy. Re-invoking the script each pass makes logic changes deploy.
26+
#
27+
# The `sleep ... & wait $!` (rather than a plain foreground `sleep`) and the
28+
# leading `trap` are so `docker compose stop`/`down` return promptly: bash
29+
# only acts on a trapped signal between foreground commands, so with a plain
30+
# `sleep 3600` a SIGTERM sent while idle would sit unhandled for up to an
31+
# hour and this container would always eat the full stop grace period before
32+
# Docker escalates to SIGKILL. Backgrounding the sleep and `wait`-ing on it
33+
# makes the trap run immediately instead.
34+
db-backup:
35+
image: "${POSTGRES_IMAGE:-postgres}"
36+
restart: always
37+
environment:
38+
- PGHOST=db
39+
- PGUSER=admin
40+
- PGPASSWORD=password
41+
- PGDATABASE=makeability
42+
- BACKUP_RETENTION_DAYS=${BACKUP_RETENTION_DAYS:-14}
43+
- BACKUP_POLL_SECONDS=${BACKUP_POLL_SECONDS:-3600}
44+
- BACKUP_RETRY_SECONDS=${BACKUP_RETRY_SECONDS:-300}
45+
volumes:
46+
- db-data:/var/lib/postgresql/data
47+
- backup-status:/var/backup-status
48+
- ./scripts:/backup-scripts:ro
49+
entrypoint: ["/bin/bash", "-c", "trap 'exit 0' TERM INT; while true; do if bash /backup-scripts/pg_backup.sh; then sleep \"$${BACKUP_POLL_SECONDS}\" & wait $!; else sleep \"$${BACKUP_RETRY_SECONDS}\" & wait $!; fi; done"]
50+
depends_on:
51+
- db
1352
website:
1453
environment:
1554
- DJANGO_ENV=${DJANGO_ENV:-TEST}
@@ -23,6 +62,7 @@ services:
2362
- .:/code
2463
- ${MEDIA_PATH:-./media}:/code/media
2564
- ${CONFIG_PATH:-./config-dev.ini}:/code/config.ini # for loading vars into ConfigParser in Django
65+
- backup-status:/var/backup-status:ro # read-only: Django reports backup health (#1443)
2666
depends_on:
2767
- db
2868
command: ["./docker-entrypoint.sh", "db", "python", "manage.py"]
@@ -32,3 +72,8 @@ volumes:
3272
db-data:
3373
external: true
3474
name: "makeabilitylabcswashingtonedu_${POSTGRES_VOLUME:-postgres-data}"
75+
# Holds only the backup status.json that Django reads to report backup health.
76+
# Intentionally NOT external and NOT something CSE IT needs to back up: it is
77+
# disposable state, regenerated on the next backup pass. The dumps themselves
78+
# live in db-data above, which is the volume that gets snapshotted.
79+
backup-status:

docs/BACKUPS.md

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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

Comments
 (0)