Backup and restore
The database lives in the Docker named volume jobradar-pg (inside Docker Desktop’s disk image; wherever Docker’s data directory is, that image is not an ordinary file and cannot replace a backup). The pg_dump files in data/backups/ are its regular copy. This directory is inside your project; if you want off-machine protection, include it in your own backup routine.
Convention: steps marked [You do this] need you to confirm or perform them personally; the other commands can be copied and run directly. Commands run in the project directory (the one that contains package.json, compose.yaml, config/ and data/) by default.
1. Automatic backup
Section titled “1. Automatic backup”Once a day the runner runs pg_dump according to the backup section of schedule.yaml (the copy in the user config data/config/); the runner image ships postgresql-client-17, the same version as postgres:17:
backup: enabled: true cron: "30 3 * * *" # 5-field cron, interpreted in the timezone at the top of the file keep_daily: 14 # the newest backup of each day, for the last 14 days keep_weekly: 8 # plus the newest backup of each ISO week, for the last 8 weeks timeout_minutes: 30 # pg_dump is aborted on timeout and counted as a failure config_archive: true # also archive data/config and data/profile next to each dump (section 1, "Configuration") pre_migrate: true # take a backup before a database migration changes anything (section 1, "Before a migration") keep_system: 5 # how many safety backups (premigrate, pre-restore, pre-deploy) of each kind to keep keep_old_databases: 1 # how many databases replaced by a restore to keep (section 4)- File:
data/backups/jobradar-YYYYMMDD-HHMMSS.dump, with the wall-clock time oftimezone.scripts/ops/backup.shandrestore.shuse the same zone, which needs the system’s time zone data (thetzdatapackage; every normal server image has it). Without itbackup.shasks the database container for the time instead of silently writing UTC, andrestore.shwarns and orders backups by file modification time, so--lateststays right. The format is pg_dump custom format (already compressed), restored withpg_restore, and you can restore only some tables. - It is first written (owner-only permissions,
0600) as a.partialfile. It is renamed only after pg_dump exited successfully, every byte was written without error, the file was flushed to disk andpg_restore --listcould read its whole table of contents, so every.dumpfile in the backup directory is complete. A write error (for example a full disk) fails the backup and removes the temporary file. The retention policy runs only after a backup passed all checks, so a failed run never costs you an older backup. - Retention (only
jobradar-YYYYMMDD-HHMMSS.dumpfiles are handled; other files are left alone): the newest backup of each day is kept for the lastkeep_dailydays, the newest backup of each week for the lastkeep_weeklyweeks, the union of the two is kept and everything else is deleted. Days and weeks are counted as “days/weeks that have a backup”, so a runner that is down for a month does not wipe the history. - Configuration: with
config_archive: true(the default) every daily backup is followed by a same-namedjobradar-YYYYMMDD-HHMMSS.config.tgz: your user configdata/config/(without its.backup/folder and without.web-session-secret, the sign-in key, which is generated again when missing) and your profiledata/profile/. They are not in the database and not in git. The archive is written after the dump (owner-only0600, read back and checked before it is accepted). If it cannot be written the dump is still kept and you get a warning (analert.backup_failednotification withkey: config_archive, and a doctor warning while the newest dump has no archive). Retention removes the archive together with its dump..env(your secrets) and.auth/(the LinkedIn login) are not in any backup: keep your own copy. The archive does containsources.yaml, whosejobspy.proxiescan hold proxy credentials (user:password@host), so treat a.config.tgzlike a secret: do not copy it off the machine unencrypted (use an encrypted destination, orage/gpgit first). - Before a migration: when the runner or
pnpm db:migratefinds that a database migration is pending, it first takes a safety backupjobradar-manual-YYYYMMDD-HHMMSS-premigrate-<applied>-<shipped>.dump(with its.config.tgz) and migrates only if that succeeded. If the backup fails the migration is not run and the runner stops with the reason, so a bad upgrade can always be undone from a dump taken just before it. A database that is already up to date, and a first install without data, are not backed up.pre_migrate: falseturns it off (not recommended). - Safety backups are pruned, manual ones are not: backups whose label starts with
premigrate,pre-restoreorpre-deployare kept bykeep_systemper kind (the newest 5 by default) and the oldest are deleted after a successful daily backup. Every other manual backup (scripts/ops/backup.sh, with or without a label) is never deleted automatically. Note that this goes by the label: a manual backup you name with--label pre-deploy-…,--label pre-restore-…or--label premigrate-…is treated as a system backup and pruned too, so do not use those prefixes for backups you want to keep. - After you save
schedule.yamlit takes effect in about 5 seconds (hot reload).enabled: falseturns automatic backup off. - If, when the runner starts, it has missed the latest backup time (for example the host was off at 03:30) and
catch_up: true, it takes one immediately.
Confirm it is working
Section titled “Confirm it is working”ls -lh data/backupsdocker compose logs runner | grep -i backupThe log should contain next backup: ... and, each time, backup finished (with file name, byte count and how many old backups were deleted).
When a backup fails
Section titled “When a backup fails”On failure the runner:
- logs
backup failedwith a reason code (pg_dump_failed,timeout,invalid_output,io, …); - writes an unsent
alert.backup_failedrecord to thenotificationstable. With Telegram pushes enabled it is sent to you; otherwise look at it with this command:
docker compose exec db psql -U jobradar -d jobradar -c "select id, payload_json from notifications where kind = 'alert.backup_failed' and sent_at is null order by id"Common causes: disk full (df -h <your data volume>), external volume not mounted (ls /Volumes), no permission on the data/backups directory (see section 1 of host-setup.md), the db container is not up (docker compose ps). After fixing it, take one by hand: scripts/ops/backup.sh.
2. Manual backup
Section titled “2. Manual backup”Before anything risky (upgrade, hand-editing the database, restore) take a backup first:
scripts/ops/backup.sh # data/backups/jobradar-manual-YYYYMMDD-HHMMSS.dumpscripts/ops/backup.sh --label before-x # with a label in the name: ...-before-x.dumpThe script needs only Docker (pg_dump runs inside the db container, so you do not need Postgres on the host). Manual backups are named manual and are never pruned by the automatic retention policy; delete them yourself when you no longer need them.
The configuration is kept as well: scripts/ops/backup.sh writes a same-named ….config.tgz next to the .dump, containing the user config directory data/config/ (without .backup/ and .web-session-secret) and the profile data/profile/. They are not in the database and not in git, so this is one of the only copies of them. The runner’s daily backup writes the same archive (see section 1).
tar -tzf data/backups/jobradar-manual-20261009-120000.config.tgz # see what is inside3. Restore drill (recommended monthly)
Section titled “3. Restore drill (recommended monthly)”By default the restore goes into a new temporary database and does not touch the main one, and prints each table’s row count side by side with the live database:
scripts/ops/restore.sh data/backups/jobradar-20261007-033000.dumpscripts/ops/restore.sh --latest # use the newest backup in data/backupsscripts/ops/restore.sh --list # show all backups, newest first, with their age--latest compares the date and time in the file names, so it finds the newest backup whether it is a scheduled one (jobradar-YYYYMMDD-HHMMSS.dump) or a manual one (jobradar-manual-YYYYMMDD-HHMMSS[-label].dump). It prints the file it chose and how old it is. If that backup is older than backup.max_age_hours of schedule.yaml (or --max-age-hours N), the script prints a warning and asks you to confirm; without a terminal it refuses unless you pass --yes (the warning is still printed).
Example output:
== restoring jobradar-20261007-033000.dump into 'jobradar_restore_20261007033500'== row counts (restored 'jobradar_restore_20261007033500' vs live 'jobradar')table restored livepublic.jobs 1234 1250...restored: 19 tables, 5678 rowslive: 19 tables, 5702 rows (it may have moved on since the backup was taken)- The number of tables should match; row counts should be slightly lower than the live database (new data arrived after the backup). If a table shows
-in thelivecolumn, the live database does not have that table yet (for example the backup came from a newer version). A-in therestoredcolumn is the opposite: the table is newer than the backup (the backup came from an older version), so it did not exist when the backup was taken; the migrations create it when the runner starts. - The temporary database is kept by default so you can inspect it:
docker compose exec db psql -U jobradar -d <temporary database name>(the script prints the full command and the delete command at the end). Delete it when you are done:
docker compose exec db psql -U jobradar -d postgres -c 'drop database "jobradar_restore_20261007033500"'Or add --drop during the drill so it is deleted automatically after verification: scripts/ops/restore.sh --latest --drop.
4. Actually restoring the main database [You do this]
Section titled “4. Actually restoring the main database [You do this]”Do this only when the main database is broken, was altered by mistake, or you need to roll back to a point in time. The script requires you to spell out the database name and --yes:
scripts/ops/restore.sh data/backups/jobradar-20261007-033000.dump --target jobradar --yesscripts/ops/restore.sh --latest --target jobradar --yes --with-config # the configuration from the same backup tooThe restore never drops the main database. The script does, in order:
- restore the backup into a temporary database
jobradar_restore_<timestamp>while web, runner and the ai-worker keep running. A damaged or unreadable backup fails here and nothing else has changed. Its row counts are printed next to the live database’s. Without--yesthe script stops here (exit status 1) and tells you what to run; - stop
webandrunner(docker compose stop web runner) and the ai-worker (macOS:launchctlwith the label fromschedule.yamldoctor.ai_worker_launchd_label; Linux: the user unitjob-radar-ai-worker.service; if neither exists you get a notice and must stop the worker yourself, because it also writes to the database); - terminate every other connection to the main database, rename it to
jobradar_before_restore_<timestamp>and give the restored copy the namejobradar(two renames; if the second fails, the first is undone). The old database is the safety copy, so no extra dump is taken and picking the wrong file costs nothing; - with
--with-config: the backup’s.config.tgzis checked before anything is touched (only regular files and directories underdata/config/anddata/profile/; links and absolute or..paths are refused) and unpacked into a staging folderdata/.restore-config-<timestamp>(no owner from the archive, folders 755, files 600, as the app writes them). After the swapdata/configanddata/profileare renamed todata/config.before-restore-<timestamp>/data/profile.before-restore-<timestamp>and the staged folders are moved in. If that last step fails, the script prints where your old and the unpacked folders are, still starts the services and exits with status 1. The option needs the main database as target; - start
web,runnerand the ai-worker again (--no-restartleaves them stopped). The runner rolls the schema forward, so a backup from an older version restores onto newer code; - keep the newest
backup.keep_old_databasesreplaced databases (default 1) and drop older ones, then print the rollback commands.
If the script is interrupted (Ctrl-C, kill, a lost terminal) or fails half-way, it undoes what it had started before it exits: it renames the main database back if the swap was half done, drops the temporary jobradar_restore_<timestamp> database it had created, removes the staging folder and starts web, runner and the ai-worker again if it had stopped them. It prints == undoing what this run left half done only when it had something to undo. If the terminal or the machine is gone before the script could do that (a power cut, kill -9), do it by hand: docker compose start web runner, and drop the leftover jobradar_restore_* databases (docker compose exec db psql -U jobradar -d postgres -c '\l' lists them).
Afterwards check: docker compose ps is all healthy; open http://127.0.0.1:3000 and look at the job list; the runner log should contain a catch-up decision (after a restore the “latest cycle” becomes the state at backup time, so it may immediately re-run a cycle; this is expected). If the backup is from an older version run pnpm config:migrate --write && pnpm config:check.
Undoing a restore
Section titled “Undoing a restore”The database as it was before stays as jobradar_before_restore_<timestamp> until a later restore replaces it. To go back, with the commands the script printed (stop web and runner first; the ai-worker too):
docker compose stop web runnerdocker compose exec db psql -U jobradar -d postgres -c 'alter database "jobradar" rename to "jobradar_after_restore_<timestamp>"'docker compose exec db psql -U jobradar -d postgres -c 'alter database "jobradar_before_restore_<timestamp>" rename to "jobradar"'docker compose start web runnerDrop the databases you no longer need with drop database "<name>"; they take disk space. After --with-config the previous configuration is in data/config.before-restore-<timestamp> and data/profile.before-restore-<timestamp>.
The script never overwrites the main database unless you pass --target jobradar --yes. Without --target it restores into a new temporary database and leaves the main one alone. With --target jobradar but without --yes it also restores into a temporary database jobradar_restore_<timestamp>, prints the row counts, and exits with status 1 and a hint to run the same command again with --yes (and how to drop the check copy); nothing else is changed.
To restore into another database (for example to pull the data of one table out for comparison): scripts/ops/restore.sh <file> --target scratch_db; if the target database already exists, --yes is required.
Restoring the configuration [You do this]
Section titled “Restoring the configuration [You do this]”Without --with-config the script never touches configuration; when a .config.tgz sits next to the backup, it prints the restore commands. To restore by hand:
cp -R data/config data/config.before-restore # first keep a copy of what is there nowtar -xzf data/backups/<name>.config.tgz -C . # overwrites data/config and data/profilepnpm config:migrate --write && pnpm config:check # if the backup is from an older version, migrate firstdocker compose restart web runner # the runner also hot-reloads within 5 secondsIf you want just one file: tar -xzf <file>.config.tgz -C /tmp/cfg data/config/ai.yaml, then compare and copy by hand. Before changing any file, config:migrate --write also puts the original into data/config/.backup/<timestamp>/; if a migration was a mistake, take it back from there.
5. Full rebuild (new machine, or the Docker data volume is lost) [You do this]
Section titled “5. Full rebuild (new machine, or the Docker data volume is lost) [You do this]”- Follow host-setup.md to install Docker, clone the repository and run
pnpm install, and put the backup files intodata/backups/(if the project is on an external volume they are already there). - Start only the database:
docker compose up -d db, and wait until db showshealthyindocker compose ps. - Restore (into an empty database, without restarting other services since they have not been created yet):
scripts/ops/restore.sh --latest --target jobradar --yes --no-restart- Put your configuration and profile back. They are not in the database; they come from the
.config.tgznext to the dump (the daily backup writes one, so doesscripts/ops/backup.sh; see Restoring the configuration). Pick the newest one, check its listing, then restoredata/configanddata/profile:
ls -t data/backups/*.config.tgz | head -n 3tar -tzf data/backups/<name>.config.tgztar -xzf data/backups/<name>.config.tgz -C .(On a fresh machine --with-config in the previous step does the same in one go, but it renames a data/config that is already there.) Without a .config.tgz you start with the defaults and have to enter your settings and profile again. Also recreate .env and .auth/ yourself (they are never backed up).
5. Start all services: docker compose up -d --build. The runner runs migrations automatically on start (the dump contains the migration records; if already up to date, nothing happens).
6. Notes
Section titled “6. Notes”- Version: dumps are produced by PostgreSQL 17 and can only be restored to a 17 or newer server. Before upgrading Postgres across a major version take a manual backup first, and upgrade
POSTGRES_CLIENT_VERSIONin the runner image to match (tests/dockerfile.test.tsfails if the two disagree). - Never
docker compose down -v:-vdeletes the database volume. .envand.auth/are in no backup:.envholds your secrets and the database password,.auth/the LinkedIn login. Keep your own copy (a password manager for the values). Your configuration and profile are in the.config.tgznext to each dump; the shipped defaults inconfig/come with the repository.- Same disk: the backups and the database are on the same disk, so they protect against mistakes, not against a failing disk. Copy
data/backups/somewhere else on a schedule. - Permissions: backup files and config archives are created readable by their owner only (
0600), because they hold your whole database and your settings. - Disk space: see
ls -lhfor the size of each backup; 14 + 8 backups (plus up to 5 safety backups of each kind) is about 22 times that size or more, so keep an eye ondf -has the data grows.
Synced from docs/ in Joevonlong/job-radar@2947f47. Job Radar is not affiliated with any job board. Code under AGPL-3.0-only.