main was 16 days and 259 commits behind the line the product is actually built on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It was neither current nor curated - just stale. This brings it forward and, at the same time, makes it mean something: main is what a receiving site installs, and nothing else. WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides, knowledgebase, machines, network, notifications, printers, slides, tools, warranty - plus applications, which is manifest-less core and always ships. The only manifest dependency inside that set is endpoints on computers, and both are in it, so the closure is self-contained. WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb stay on the site line. So do the Windows installer tree, the VM rehearsal helpers, the agent tooling config, one site's data loader, the internal audit and findings records, and the planner spreadsheet. 1,135 tracked files against 1,438. A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped: deploy/site-profile-universal.json still named all seventeen plugins. It is the file docs/DEPLOY.md tells a site to provision from, so a fresh install would have failed at `flask plugin apply-profile` with PluginError - the first thing a receiving site does, broken by the branch meant to serve them. It now names the twelve that exist, and its comment says the two must agree. agent/ was never refreshed. Whoever populated this branch copied shopdb/, plugins/, frontend/ and tests/ from the site line and left the agent tree at its September state: 22 C# and PowerShell sources missing, including AgentCredentials, EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the site line had deleted still present. The old tree is internally consistent, so it compiles, and nothing in CI builds it - every gate was green over a server that speaks enrollment and per-PC credentials to a bundled agent source that cannot. Resynced whole. DECISIONS THIS ENCODES, rather than defers: - The plugin-authoring lab builds printedparts, whose manifest depends on employees, so it could not stay. The lab remains on the site line and main ships no build-along tutorial. That is a real loss for an adopting developer and is recorded here rather than discovered later. - Recognition, Training and Recertification notifications are no longer seeded. Each splits one notification into a card per employee and renders a photo, which needs a plugin this branch does not bundle. Seeding them would offer an author a type that cannot resolve anybody. A site that installs employees gets them from that plugin's own seed. - Windows/IIS is documented as the legacy path throughout, and the README leads with Docker, because that is what a receiving site gets. The API description is regenerated for THIS branch - 347 paths and 483 operations, down from 415 and 573 - so the published contract describes what is here rather than what the site line serves. docs/CONFIG.md lost the two measuring-tool label keys the code no longer declares, which two lanes had decided opposite ways. Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run rather than skip, and every index line count is derived from the file it names.
7.8 KiB
Backup and restore the ShopDB database
This is the ShopDB instance itself. For the per-PC CONFIG backups the collector takes off shopfloor machines, see BACKUP-KINDS.md.
Each site owns its own data (single-tenant, ADR-004), so backups are the site's
responsibility. A complete backup is two parts, and a database-only backup is
not a backup: restoring the database without the matching instance/
directory leaves the app pointing at floor plans and logos that no longer exist.
| Item | Location | Why |
|---|---|---|
| Database | MySQL shopdb_flask |
All asset, user, audit and settings data. |
instance/branding/ |
instance/ dir |
Uploaded logos and favicon. |
instance/modelimages/ |
instance/ dir |
Uploaded vendor-model photos. |
instance/ floor plans |
instance/ dir |
Uploaded map blueprints. |
instance/plugins.json |
instance/ dir |
Which plugins this site enabled. |
.env |
repo root (offline, secured) | Secrets needed to bring the stack back up. Store separately from the data backup, in a secrets manager. |
The shipped Docker backup overlay
docker-compose.backup.yml is an opt-in per-site overlay that automates all of
the above. Turn it on with:
docker compose -f docker-compose.yml -f docker-compose.backup.yml \
--profile backup up -d
It adds three cooperating services: backup-store, a resident restic REST
server holding every snapshot in its own volume, deliberately NOT published to
the host so backups are reachable only over the compose network; backup-watcher,
which actuates the "Back up now" sentinel the api drops, because the api
container cannot run docker itself; and backup, the same one-shot run manually
(run --rm backup) for sites that prefer a host scheduler.
Each run captures one recoverable set: db.sql.gz (a non-blocking
mysqldump --single-transaction), a restic snapshot of instance/, and
manifest.json recording the core and plugin Alembic revisions plus the image
tag at capture time. That manifest is what makes a restore a CHECKED operation:
scripts/backup/restore.sh refuses to load a set onto code whose schema is
older than the set's, rather than leaving the site half-broken with rows
referencing columns the live schema has not applied. It does NOT roll back code
- restore the code first, run
flask db upgradeandflask plugin upgrade-all, then load the data.
Retention is restic-native: --keep-daily 5 --keep-weekly 4 --keep-monthly 6.
RESTIC_REPOSITORY defaults to the bundled store and can point at S3, Azure
Blob or a mounted share instead; credentials come from the site's env or secret
store, never committed.
Backing up by hand
Database (Docker)
docker compose exec -T db mysqldump \
-u root -p"${MYSQL_ROOT_PASSWORD}" \
--single-transaction --routines --triggers \
shopdb_flask | gzip > shopdb-$(date +%F).sql.gz
--single-transaction gives a consistent dump without locking the tables (InnoDB).
Database (external MySQL, no container)
mysqldump -h <host> -u <user> -p \
--single-transaction --routines --triggers \
shopdb_flask | gzip > shopdb-$(date +%F).sql.gz
instance directory
tar czf instance-$(date +%F).tar.gz instance/
Recommended cadence: nightly database dump to offsite storage, 14-day
retention; instance/ captured on the same schedule (and always right before an
upgrade). Verify a restore quarterly.
Restore
Restoring replaces the current database contents. Do it into a known-empty or a throwaway target first if you are unsure.
Step 1: Bring up the stack (or a fresh one)
cp .env.example .env # or restore your saved .env
# ensure MYSQL_* and DATABASE_URL match the dump's database name (shopdb_flask)
docker compose up -d db
Wait for the db container to report healthy (docker compose ps).
Step 2: Load the database dump
gunzip -c shopdb-2026-07-10.sql.gz | \
docker compose exec -T db mysql -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask
For an external MySQL:
gunzip -c shopdb-2026-07-10.sql.gz | mysql -h <host> -u <user> -p shopdb_flask
If the target database does not exist yet, create it as utf8mb4 first (matching the schema charset):
CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Step 3: Restore the instance directory
On Docker, instance/ is a named volume, not the host directory. The api
service mounts instance_data:/app/instance, so extracting the tarball into
./instance on the host restores nothing the application can see. The data has
to go into the volume:
tar xzf instance-2026-07-10.tar.gz # unpacks ./instance on the host
docker compose up -d api # creates the container and volume
docker compose cp instance/. api:/app/instance # copy into the volume
docker compose restart api
Check it landed before going on:
docker compose exec api ls /app/instance
plugins.json must be there. Without it the site comes back with its plugins
disabled and database rows pointing at uploaded files that are no longer
present.
A site that has replaced the named volume with its own bind mount
(- ./instance:/app/instance) restores by unpacking the tarball on the host and
nothing else. Check docker-compose.yml rather than assuming which one you have.
For a non-docker deploy, unpack the tarball over the application directory:
instance/ is a plain directory there.
Step 4: Reconcile migrations
docker compose exec api flask db upgrade
docker compose exec api flask plugin upgrade-all
For a non-docker deploy, the same two commands without the prefix.
flask db upgrade is a safety net: if the dump predates the current code, this
applies only the core Alembic chain. flask plugin upgrade-all then applies any
newer per-plugin migrations (each bundled plugin owns its own chain, ADR-008);
without it, plugin-owned tables stay un-migrated. If the dump is at the same
version both are no-ops.
Step 5: Verify
- Log in with a known account.
- Confirm the floor map renders (branding and map blueprints resolve from
instance/). - Spot-check a few asset records and the audit log.
curl -s -X POST -H "Content-Type: application/json" -d '{}' http://localhost:5001/api/auth/login | jq .should return aVALIDATION_ERROR, not a 500.
What is lost if .env is lost
.env is not in the database dump, and rebuilding it is not simply retyping it:
| Value | If it is lost |
|---|---|
SECRET_KEY, JWT_SECRET_KEY |
Every issued token and session becomes invalid. Users log in again; managed API tokens must be reissued, which means every collector client needs its key replaced. Recoverable, but it is a fleet-wide job. |
DATABASE_URL password |
Recoverable: reset the MySQL user's password and write the new one in. |
MYSQL_ROOT_PASSWORD |
Recoverable through MySQL's own reset procedure, which requires stopping the server. |
RESTIC_PASSWORD |
NOT recoverable. Every restic snapshot in the repository is encrypted with it; without it the backups are unreadable by anyone, including you. |
ZABBIX_TOKEN and similar integration tokens |
Reissue at the far end. Nothing else breaks. |
So back it up with the database, to somewhere as protected as the dump. A dump
without its .env restores the data and locks everyone out of it.
See also
- BACKUP-KINDS.md - the other backups: per-PC config snapshots taken by the collector. A different subject entirely.
- DEPLOY.md - first-time deploy
- UPGRADE.md - upgrade procedure (back up first)
- CONFIG.md - environment variables and Setting keys