Files
shopdb-flask/docs/BACKUP-RESTORE.md
cproudlock 928a50c16e docs: what a site needs that no page answered
Four gaps a second site hits and cannot resolve by reading.

**Restoring on Windows** was one sentence - "the standard mysql < dump.sql" -
with no ordering. Restoring a database under running code that expects a
different schema turns a restore into a second incident, so the steps are now
ordered and each says why. It also says what `.env` costs if it is lost, which
is the part nobody discovers until they are already rebuilding: the dump does
not contain it, and without the JWT secrets every issued token dies, so every
collector and every GE-Enforce client on the fleet needs a new key.

**Rolling back** had a paragraph saying downgrades are refused and a backup is
the way back, but not the procedure. Rollback is restoring a matched pair, code
and the schema it expects, in that order - and the doc now separates it from the
case it gets confused with: a migration that failed mid-update has already been
rolled back by the installer, and fixing forward is the only move.

**Sizing, acquisition and support** were absent from the install guide entirely.
A reader could not learn how big a server to ask for, where the .exe comes from,
or where to raise a problem. The sizing is small and the reasons are stated, so
a site does not over-provision a VM for a load that is a few dozen users.

**Credentials** were described in three documents from three ends, so three
answers existed for where a key lives. One table, both ends - server and PC -
plus the two rules behind it: what a shop-floor PC holds is scoped to exactly
what it does, and a credential is delivered rather than typed, because a value
entered per machine is a value that is wrong on some machine.
2026-08-14 16:16:01 -04:00

8.1 KiB

Backup and Restore

Each site owns its own data (single-tenant, ADR-004), so backups are the site's responsibility. A complete backup is two parts:

  1. The MySQL database - all asset, user, audit, and settings data.
  2. The instance/ directory - uploaded floor plans, branding assets, plugins.json (the enabled-plugin list), and any tokens or files the app writes to disk. These are NOT in the database, so a DB-only backup loses them. Back up instance/ alongside every database dump.

Restoring the database without the matching instance/ directory leaves the app pointing at floor plans and logos that no longer exist.

What to back up

Item Location Why
Database MySQL shopdb_flask All application data.
instance/branding/ repo instance/ dir Uploaded logos and favicon.
instance/modelimages/ repo instance/ dir Uploaded vendor-model photos.
instance/employeephotos/ repo instance/ dir Uploaded self-hosted employee photos (external mode serves photos from the HR database instead).
instance/ floor plans repo instance/ dir Uploaded map blueprints.
instance/plugins.json repo 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.

Backup

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

tar xzf instance-2026-07-10.tar.gz    # restores ./instance/

The default docker-compose.yml does NOT bind-mount instance/ into the api container (its only volume is - ./plugins:/app/plugins:ro, and the image never copies instance/), so the container's Flask instance path is an empty /app/instance and a restored host ./instance is invisible to it. To make the restored instance/ visible, add a bind mount to the api service before starting it:

  api:
    volumes:
      - ./plugins:/app/plugins:ro
      - ./instance:/app/instance

Make sure ./instance is present on the host before starting api.

Step 4: Bring up the API and reconcile migrations

docker compose up -d api
docker compose exec api flask db upgrade
docker compose exec api flask plugin upgrade-all

For a non-docker deploy:

flask db upgrade
flask plugin upgrade-all

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 a VALIDATION_ERROR, not a 500.

Windows sites (installer-built)

On a server installed from the Windows installer, everything above is wrapped by the operator console. Do not run mysqldump by hand:

cd C:\shopdb-flask
.\shopdb-admin.ps1 backup            # C:\ProgramData\ShopDB-Flask\backups
.\shopdb-admin.ps1 backup D:\backups

The dump is verified complete before it is reported as good; a truncated one is deleted rather than left to be discovered when it is needed. An upgrade takes its own backup automatically before touching the schema, and restores from it if a migration fails.

Two Windows-specific notes:

  • The backup directory is locked to Administrators and SYSTEM, because a dump contains every row including user password hashes. Keep it that way.
  • mysqldump must be present. It ships with the bundled-database option; a site using a remote MySQL needs mysqlclient\ in its installer bundle, or the pre-upgrade backup is skipped. shopdb-admin.ps1 check reports this.

Restoring on Windows, in order

Order matters. Restoring the database under running code that expects a different schema is how a restore turns into a second incident.

cd C:\shopdb-flask

# 1. Stop serving. The pool, not the whole site: other applications on this
#    IIS server are unaffected.
.\shopdb-admin.ps1 stop

# 2. Restore the database. Use the dump taken closest BEFORE the problem,
#    not the newest one - the newest may already contain it.
mysql -u root -p shopdb_flask < C:\ProgramData\ShopDB-Flask\backups\shopdb_flask-pre-upgrade-20260814-0730.sql

# 3. Restore instance\ if you are rebuilding a server rather than just
#    reverting data. It holds uploaded branding, map blueprints, application
#    images and warranty proofs - none of which are in the database.
robocopy D:\backups\instance C:\shopdb-flask\instance /MIR

# 4. Start, then prove it.
.\shopdb-admin.ps1 start
.\shopdb-admin.ps1 check -Json

check -Json reports version, publishing method, IIS and pool state, HTTP reachability, database reachability, Python version and installed plugins. If it passes, the restore worked; if the version it reports is not the version you expect, see "Rolling back a release" in UPDATES-WINDOWS.md, because a restored database and newer code is the one combination the installer cannot fix for you.

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 and GE-Enforce 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.
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 - it is ACL'd to Administrators and SYSTEM on the server for the same reason. A dump without its .env restores the data and locks everyone out of it.

See OPERATE-WINDOWS.md.

See also