Full backup -> verify -> restore -> start-the-app rehearsal of the deploy.sh backup path against MariaDB 11. Verified: the real backup_database function selected the MariaDB path from DATABASE_PROVIDER=mariadb, produced a valid .sql.gz with 42 CREATE TABLE statements and an intact "Dump completed" trailer, restored into a separate empty MariaDB container, and the application then started healthy against the restored database with the reconciler finding nothing to do. All 42 tables matched on row count, and content survived including foreign key relationships and career profile JSON. This is a rehearsal, NOT a verification of production data. No production host was contacted and no production data was read. This machine has no route to production: no /opt/job-tracker, no DATABASE_PROVIDER or connection string in its .env, and the local stack runs SQLite. Production host, user and key are CI secrets not available here. The document leads with that scope limit, records the commands to run against production with values substituted, and ends with the checklist that actually closes the gap -- including checking that non-ASCII CV text survives the round trip, which the ASCII-heavy seed data did not prove. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.6 KiB
Backup and restore verification
2026-07-19 19:32 UTC. Full backup → verify → restore → start-the-app rehearsal of the
deploy/deploy.shbackup path, exercised end to end against MariaDB 11.This is a rehearsal, not a verification of production data. See Limitations — that section is the most important part of this document, and the checklist at the end is what actually closes the gap.
Scope — read this first
| What was verified | The backup mechanism: the real backup_database function from deploy.sh, against a real MariaDB 11 database carrying the real 42-table schema, restored into a separate clean MariaDB 11 container, with the real application started against the result |
| What was NOT verified | Your production database. No production host was contacted, no production credentials were used, and no production data was read, copied or restored |
| Why | The machine this ran on has no route to production: no /opt/job-tracker, no DATABASE_PROVIDER or JOBTRACKER_CONNECTION_STRING in its .env, and the local stack runs SQLite. The production host, user and key are CI secrets (PROD_HOST, PROD_USER, PROD_SSH_KEY) that are not available here |
The commands below are the ones to run against production. They are recorded so the owner can execute the same sequence with production values substituted.
1. Backup configuration
Read from deploy/deploy.sh and docker-compose.yml:
deploy.shloads/opt/job-tracker/shared/.envinto its own shell before any decision.validate_deploy_configruns before the backup, and therefore before any build, stop or replace. It requiresDATABASE_PROVIDER(no default),JOBTRACKER_CONNECTION_STRINGwhen the provider is MariaDB, plusAI_SERVICE_TOKENandAUTH_JWT_KEY.- Backups land in
/opt/job-tracker/backups(override withBACKUP_DIR), UTC-timestamped, gzipped. Nothing is ever overwritten and nothing is ever auto-deleted. - The password travels via
MYSQL_PWD, never on the command line.
2. Provider resolution
DATABASE_PROVIDER=mariadb selected the MariaDB dump path. Confirmed by the script's own output:
Deployment configuration validated (database provider: mariadb).
Backing up MariaDB database 'jobtracker' on <host>:<port> to <dir>/jobtracker-jobtracker-20260719T193022Z.sql.gz
Backup verified: <dir>/jobtracker-jobtracker-20260719T193022Z.sql.gz (8.0K)
The filename is the check that matters. jobtracker-<database>-<stamp>.sql.gz means the MariaDB
path ran. A jobtracker-sqlite-<stamp>.tar.gz on a MariaDB host would mean the environment is wrong.
3. Commands used
Schema built by the real application, not by hand — Database.Migrate() plus the startup reconciler
against an empty MariaDB, producing 42 tables. Representative rows were then seeded across users,
companies, applications, career profile and CV variants.
# Backup — the real function from deploy.sh, not a hand-written dump
DATABASE_PROVIDER=mariadb \
JOBTRACKER_CONNECTION_STRING='Server=<host>;Port=<port>;Database=jobtracker;User Id=<user>;Password=<password>;' \
BACKUP_DIR=/opt/job-tracker/backups \
deploy/deploy.sh # takes the backup first and aborts the deploy if it fails
# Integrity and content
gzip -t "$BACKUP" # valid archive
gzip -dc "$BACKUP" | grep -c 'CREATE TABLE' # schema present
gzip -dc "$BACKUP" | tail -1 # "-- Dump completed on ..." trailer
# Restore into a SEPARATE, empty database — never over a live one
gzip -dc "$BACKUP" | MYSQL_PWD='<password>' mariadb --host=<host> --user=<user> jobtracker
Passwords are supplied via MYSQL_PWD so they never reach the process list or the shell history.
4. Backup verification result
| Check | Result |
|---|---|
| Correct backup type | ✅ .sql.gz MariaDB dump, not a volume archive |
| File integrity | ✅ gzip -t passed |
| Schema markers | ✅ 42 CREATE TABLE statements |
| Dump trailer | ✅ -- Dump completed on 2026-07-19 19:30:23 — proves the dump was not truncated |
| Expected tables | ✅ AspNetUsers, Companies, JobApplications, CareerProfiles, CvVariants, JobEvents, ApplicationChecklistItems, AiInteractions all present |
| Data present | ✅ 7 INSERT INTO statements |
5. Restore result
Restored into a separate, empty MariaDB 11 container (0 tables before, 42 after).
| Entity | Source | Restored | |
|---|---|---|---|
| Users | 1 | 1 | ✅ |
| Companies | 2 | 2 | ✅ |
| Applications | 2 | 2 | ✅ |
| Career profiles | 1 | 1 | ✅ |
| CV variants | 1 | 1 | ✅ |
Beyond counts:
- All 42 tables compared — table lists identical, and every table's row count matched.
- Content survived, not just cardinality — the restored applications resolve their company
foreign keys (
Senior Backend Engineer @ Northwind Consulting), the CV variant kept its name andIsPublicflag, and the career profile JSON still contained its languages. - The application starts against the restored database.
/healthreturned{"status":"ok","version":"restore-rehearsal"}, the schema stayed at 42 tables (the reconciler correctly found nothing to do) and rows were preserved. A restore that produces a database the app cannot boot against is not a restore.
Limitations
Read these before treating the deployment as backed up.
- No production data was touched. Everything above ran against a locally built MariaDB with seeded rows. It proves the mechanism; it proves nothing about your database.
- The seeded dataset is tiny (7 rows). It does not exercise dump duration, disk headroom, lock
behaviour under load, or timeout limits on a real dataset. A production database large enough to
make
mariadb-dumpslow could behave differently. - Character-set and collation fidelity was not stress-tested. The seeded data was mostly ASCII.
Real CV content contains non-ASCII text — Norwegian
æøå, accents, CJK. The dump defaults should handle this, but it was not proven here. - No restore was performed over a populated database. The destination was empty. Restoring over an existing database is a different operation with different failure modes.
deploy.shwas exercised up to and including the backup, not through the build and container replacement, which would have required a full deployment.- The
mariadb-dumpclient came from a container (mariadb:11). If the production host has its own client installed,deploy.shuses that instead, and version differences are possible.
What the owner still needs to do
This is the checklist that turns a rehearsal into a verified backup.
- Run one backup by hand against production and confirm the filename is
jobtracker-<database>-<stamp>.sql.gz. - Check the dump size is plausible for the amount of data you have. A suspiciously small file is the signal worth catching.
- Restore it into a scratch database — never over the live one — and confirm row counts for
AspNetUsers,JobApplications,CompaniesandCareerProfilesmatch production. - Check non-ASCII text survived. Open one CV or career profile containing
æ,øoråin the restored copy and confirm it is not mangled. This is the most likely silent failure. - Confirm
/opt/job-tracker/backupshas disk headroom for several dumps. - Note how long the dump takes. It runs before every deploy and blocks it.
Cleanup
Both MariaDB containers and all rehearsal files were removed. No test artefacts remain, and the
existing local jobtracker-backend-1 / jobtracker-frontend-1 stack was left untouched.