Operational Runbooks
Step-by-step procedures for operating SAPOT in production. Each runbook includes a verification step — do not consider a procedure complete until verification passes.
Backup and restore (MariaDB)
When: Before any schema change (see ADR 0007), on a regular schedule if the deployment is long-running, and always before a disaster-recovery restore.
Backup (automated)
Backups run unattended via sapot-db-backup.timer. Both deployment paths use the same two unit files and the same script; only how they reach the host differs.
Docker bundle: nothing to do. The bundle carries the units in its systemd/ directory, and install.sh copies them to /etc/systemd/system/, creates the sapot account they run as, and enables the timer. upgrade.sh and rollback.sh refresh the unit files so a changed unit travels with its release, but neither enables anything — a timer you deliberately disable stays disabled.
Bare-metal: copy the units out of the checkout and enable the timer once. A bare-metal host is not managed by the bundle installer, so this stays manual.
sudo cp /home/sapot/YLP-software/deployment-scripts/sapot-db-backup.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now sapot-db-backup.timer
systemctl list-timers sapot-db-backup.timer
The service picks /opt/sapot/releases/current/scripts/backup-db.sh when a bundle is installed and falls back to the bare-metal checkout otherwise, so one unit file serves both paths unmodified.
What the bundle installer grants the sapot account
install.sh runs under sudo and creates /opt/sapot as root, so it also provisions the unprivileged account the units run as: a system user with no login shell, added to the docker group (bundle-mode backups shell out to docker compose), owning shared/server.env, shared/db-backups, and .lock. Those are exactly the three paths a scheduled run touches — the env file it reads DATABASE_URL from, the directory it writes dumps to, and the lock that keeps a backup from starting mid-migration.
Owning server.env lets that account read the database password and JWT_SECRET_KEY, which are otherwise root-only. If that trade is unacceptable at your site, run the unit as root instead and hand the file back:
sudo systemctl edit sapot-db-backup.service # [Service] then User=root
sudo chown root:root /opt/sapot/shared/server.env
Dumps are then owned by root. Bare-metal hosts already run their services as sapot and need neither change.
Cadence
Default is daily, with up to 15 minutes of jitter. For a standing or dev environment, change it to weekly with sudo systemctl edit sapot-db-backup.timer and add [Timer] plus OnCalendar=weekly. The timer is Persistent=true, so a host that was powered off through its scheduled window runs the missed backup shortly after next boot rather than skipping the day, so expect a backup to appear on startup at a field site that shuts down overnight.
Settings
Settings go in /etc/sapot/backup.env (mode 600, optional; all have defaults):
| Variable | Default | Meaning |
|---|---|---|
SAPOT_BACKUP_DIR | /opt/sapot/shared/db-backups (bundle) or /home/sapot/backups (bare-metal) | Where dumps are written |
SAPOT_SERVER_ENV | /opt/sapot/shared/server.env (bundle) or /home/sapot/YLP-software/server/.env (bare-metal) | File the script reads DATABASE_URL from; set this if the checkout lives elsewhere |
SAPOT_BACKUP_RETENTION_DAYS | 14 | Dumps older than this are deleted |
SAPOT_BACKUP_MIN_KEEP | 3 | Newest N dumps are kept regardless of age |
SAPOT_BACKUP_OFFHOST_DIR | unset | Mountpoint of the removable drive to copy to |
SAPOT_BACKUP_MAX_AGE_HOURS | 36 | Age at which doctor.sh reports the backup stale |
Dumps are named sapot_db_<UTC timestamp>.sql.gz. Each is verified for gzip integrity and mysqldump's completion footer before receiving its final name.
systemctl status sapot-db-backup.timer
journalctl -u sapot-db-backup.service -n 50 --no-pager
/opt/sapot/releases/current/scripts/doctor.sh
Off-host copies
Set SAPOT_BACKUP_OFFHOST_DIR to a removable drive's mountpoint to copy each verified dump off-host. Do this wherever the site allows it: a backup that lives only on the machine it protects against does not survive that machine's hardware failure, which is the disaster this runbook exists for. An absent or unwritable drive logs a warning but does not discard the on-host dump. The script never deletes copies on removable media, so that drive's capacity is yours to manage.
Protecting dumps
A dump is a complete, unencrypted copy of the database, including account records and message rows. The script writes it under whatever umask it inherits and never restricts the mode itself, so an operator has to set that. Restrict the directory, and set the service's umask so future dumps are owner-only:
sudo chmod 700 /opt/sapot/shared/db-backups # or /home/sapot/backups
sudo chmod 600 /opt/sapot/shared/db-backups/*.sql.gz # existing dumps
sudo systemctl edit sapot-db-backup.service # [Service] then UMask=0077
Apply the same reasoning to the off-host drive: it carries the same data as the server, so it needs the same physical handling as the server itself, not a shared or general-purpose stick.
Backup (manual, ad hoc)
Run the script directly for a pre-migration or pre-teardown backup. It takes the same lock and the same settings as the timer, so a manual run and a scheduled one cannot collide.
/home/sapot/YLP-software/deploy/scripts/backup-db.sh # bare-metal
/opt/sapot/releases/current/scripts/backup-db.sh # bundle
--dry-run prints the resolved mode, paths, retention settings, and exactly which dumps retention would delete, then exits without connecting to the database or writing anything. Use it to confirm configuration after editing /etc/sapot/backup.env.
/home/sapot/YLP-software/deploy/scripts/backup-db.sh --dry-run
Restore
Restore is deliberately manual. Dumps are gzipped, and the database is sapot_db on bare-metal and sapot in a bundle. Confirm against DATABASE_URL in the host's server env file before typing a name.
Bare-metal:
sudo systemctl stop server-main-api
zcat /home/sapot/backups/sapot_db_20260807T020000Z.sql.gz | mysql -u sapot -p sapot_db
sudo systemctl start server-main-api
Bundle:
cd /opt/sapot/releases/current
docker compose -p sapot -f compose/docker-compose.yml stop api
zcat /opt/sapot/shared/db-backups/sapot_db_20260807T020000Z.sql.gz | docker compose -p sapot -f compose/docker-compose.yml exec -T db mysql -u sapot -p sapot
docker compose -p sapot -f compose/docker-compose.yml start api
Then reconcile the schema revision. A dump carries the alembic_version row from the moment it was taken, so restoring an older dump under a newer release leaves the database behind the code that is about to query it (ADR 0007). Always check, and upgrade if they differ. Never use alembic downgrade to close the gap: downgrading the baseline drops every table.
# bare-metal, with DATABASE_URL set, from /home/sapot/YLP-software/server
alembic current && alembic upgrade head
# bundle
cd /opt/sapot/releases/current
docker compose -p sapot -f compose/docker-compose.yml run --rm api alembic current
docker compose -p sapot -f compose/docker-compose.yml run --rm api alembic upgrade head
Verification (bare-metal):
mysql -u sapot -p -e "SELECT COUNT(*) FROM sapot_db.peer;"
curl -k https://localhost/auth/exists?identifier=probe@example.com
sudo journalctl -u server-main-api -n 50 --no-pager
Verification (bundle). A bundle host has no host-side mysql client and no server-main-api unit; the database and API are containers:
cd /opt/sapot/releases/current
docker compose -p sapot -f compose/docker-compose.yml exec -T db \
mariadb -u sapot -p sapot -e "SELECT COUNT(*) FROM peer;"
curl -k https://localhost/version
docker compose -p sapot -f compose/docker-compose.yml logs --tail 50 api
Applying schema migrations (Alembic)
When: Deploying server code whose SQLModel classes in server/app/models/ changed. See migrations.md and ADR 0007.
server/runserver.sh already runs alembic upgrade head before starting gunicorn, so a normal deploy needs no separate step. Use this runbook when applying migrations out of band, or when a deploy fails at the migration step.
- Back up first — run the backup procedure above. Restoring from backup is the rollback path;
alembic downgradeis a CI verification tool, and downgrading the baseline drops every table. - Check what the database is currently on:
If this prints nothing, the database predates Alembic and needs the one-time cutover instead. Do not runcd /home/sapot/YLP-software/serverexport DATABASE_URL='mysql+pymysql://sapot:sapot@127.0.0.1:3306/sapot_db'alembic current
upgradeon it; it will try to create tables that already exist. - Review what is about to be applied:
alembic history --verbose
- Apply:
alembic upgrade head
- Restart the service:
sudo systemctl restart server-main-api
Verification:
alembic current # expect the new head revision
alembic check # expect "No new upgrade operations detected"
sudo journalctl -u server-main-api -n 50 --no-pager # confirm no startup errors after restart
Rollback if it goes wrong: stop the service, restore from the pre-change backup (restore procedure), redeploy the previous server code version, restart.
Offline CA Setup
When: One time, to establish the private certificate authority used by all subsequent server leaf issuance. This procedure is performed on an offline machine (or an air-gapped network partition) to protect the root CA private key.
Create the Root CA (offline machine)
-
On an offline machine (not the server host), generate the private CA:
openssl req -x509 -newkey rsa:4096 -nodes -days 3650 \-keyout server_ca.key -out server_ca.pem \-subj "/CN=SAPOT LAN Root CA"-nodesmeans no passphrase (acceptable for an offline, air-gapped CA)-days 3650= 10 years validity- This generates two files:
server_ca.key(private, keep offline) andserver_ca.pem(public, distribute to mobile app)
-
Move the CA onto a dedicated USB stick and secure it:
- Copy
server_ca.keyandserver_ca.pemonto a USB stick reserved for this purpose, then wipe them from the machine that generated them - Store the stick in a physically secure location (safe, locked drawer, vault) and document who may draw it out
- The stick is plugged into a server only while issuing a certificate, and unplugged immediately afterwards (TLS certificate rotation below)
- The stick must be mounted read-write: issuance appends to
server_ca.srlandissued-leaves.logon it
- Copy
-
Distribute the public certificate:
- Copy
server_ca.pem(public key only) to the mobile app repository as the trust anchor (Task 1.1) - Share
server_ca.pemwith any other clients that need to verify server certs
- Copy
Verification:
openssl x509 -in server_ca.pem -noout -text | grep -A1 "Subject:"
# expect: Subject: CN = SAPOT LAN Root CA
openssl x509 -in server_ca.pem -noout -dates
# confirm notBefore and notAfter span 10 years
TLS certificate rotation (CA-pinned server leaf)
When: Before the server leaf certificate expires, or immediately if the private key may have been exposed. This runbook re-issues a new server leaf from the offline CA (see Offline CA Setup above).
Trust model. Issuance happens entirely on the server, against the CA USB stick plugged into it. One run generates the key, signs the leaf, and installs it — there is no CSR to carry and no second machine involved.
The CA private key is therefore readable on a LAN-connected host for the duration of that run. This is a deliberate trade: it removes the transport USB stick, the round trip to a signing laptop, and the digest cross-check that existed to detect tampering on that trip, in exchange for the CA key touching the server. What protects the CA is physical control of the stick, so:
- Plug the stick in only to issue, and unplug it the moment the run finishes.
- Never leave it attached across a reboot or an unattended period.
- Store it as described in Offline CA Setup between uses.
- If the server is ever suspected compromised while the stick was attached, treat the CA as compromised: issue a new CA, rebuild and redistribute the mobile app, and reissue every server leaf.
Every leaf must carry DNS:server.sapot.lan as a SAN, alongside the server's LAN IP and localhost. Mobile preview/production builds connect to that name and scope their CA pin to it, so a leaf missing it fails TLS hostname verification on every production handset regardless of the IP (mobile-eas.md). request-cert.sh refuses to sign a CSR that lacks it, and on docker-bundle installs doctor.sh fails its certificate check if the DNS name is absent.
Both deployment paths below start by plugging the CA USB stick into the server and confirming it is mounted read-write (lsblk -f).
Path A: docker-bundle deployment
Certificates live at $SAPOT_ROOT/shared/certs/server.crt (default $SAPOT_ROOT = /opt/sapot). See docker-bundle.md.
-
With the CA USB stick plugged in, issue the leaf:
sudo /opt/sapot/releases/current/scripts/request-cert.shThis is the whole procedure — it generates the key and CSR, signs against the CA on the stick, verifies the result, and installs the leaf. Specifically it:
- Finds the stick by looking for a directory holding both
server_ca.pemandserver_ca.keyunder/media/*/*,/media/*,/run/media/*/*,/mnt/*/*,/mnt/*. Pass--ca-dir <mount>if it is mounted elsewhere, or if more than one candidate matches (the script refuses to guess). - Validates the stick before generating anything: that it is a real mount rather than a stale mountpoint left by an unplugged drive (override with
SAPOT_CA_ALLOW_LOCAL=1for testing against a scratch CA only), that it is writable, and thatserver_ca.pemis an unexpired CA certificate matchingserver_ca.key. It prints the CA's subject and validity so you can confirm which CA is signing. - Detects the server's LAN IP automatically (prompts if detection fails) and requests SAN
DNS:server.sapot.lan,IP:<detected IP>,DNS:localhost. The DNS name comes fromSAPOT_SERVER_DNS_NAMEindeploy/scripts/lib/deploy-common.sh, and signing is refused if the CSR does not carry it. - Reuses the existing
server.keyif there is one. Add--rotate-keyto generate a fresh private key instead; the superseded leaf is kept alongside asserver.crt.stale-<timestamp>for audit.--days <n>overrides the 825-day default. - Appends a record to
issued-leaves.logon the stick, and stamps the CA's publicserver_ca.pemnext to the leaf as the trust anchordoctor.shchecks against.
Issuance is non-destructive: the new leaf is staged and only replaces
server.crtonce it verifies against the CA, so a failed run leaves the working cert in place and TLS keeps running.--force, required by the retired CSR-transport workflow, is accepted and ignored. - Finds the stick by looking for a directory holding both
-
Unplug the CA USB stick.
-
Recreate
nginxto pick up the new cert.request-cert.shprints the exact command for your install state:- Server already has a
releases/currentinstall:sudo docker compose -p sapot -f /opt/sapot/releases/current/compose/docker-compose.yml up -d --force-recreate nginx - Fresh install (no
releases/currentyet) — noteinstall.shissues the leaf itself, so this step is only needed if you ranrequest-cert.shfirst:sudo ./scripts/install.sh
- Server already has a
Path B: bare-metal/systemd deployment
Certificates live at /home/sapot/certs/server.crt (server.md, overview.md). request-cert.sh ships with the docker bundle and is not available here, so run the same steps with openssl directly.
-
With the CA USB stick mounted (
CA_USBbelow), generate a key and CSR. SetSERVER_LAN_IPto this host's actual LAN address:CA_USB="/media/$USER/ca-usb"SERVER_LAN_IP="192.168.0.100"openssl req -newkey rsa:2048 -nodes \-keyout server.key.new -out server.csr \-subj "/CN=$SERVER_LAN_IP" \-addext "subjectAltName=DNS:server.sapot.lan,IP:$SERVER_LAN_IP,DNS:localhost"-addextis required:openssl x509 -reqdoes not copy extensions from the CSR, so without it you get a CN-only cert that modern clients reject. -
Sign it against the CA on the stick. The extension file carries the SAN forward and constrains the leaf:
cat > leaf.ext <<'EXT'subjectAltName=DNS:server.sapot.lan,IP:192.168.0.100,DNS:localhostbasicConstraints=CA:FALSEkeyUsage=digitalSignature,keyEnciphermentextendedKeyUsage=serverAuthEXTopenssl x509 -req -in server.csr \-CA "$CA_USB/server_ca.pem" -CAkey "$CA_USB/server_ca.key" \-CAcreateserial -CAserial "$CA_USB/server_ca.srl" \-days 825 -extfile leaf.ext -out server.crt.newEdit the
subjectAltNameline to match$SERVER_LAN_IP— the heredoc is quoted, so it does not expand variables. -
Verify before installing anything, and record the issuance on the stick:
openssl verify -CAfile "$CA_USB/server_ca.pem" server.crt.new # expect: OKprintf '%s serial=%s CN=%s out=server.crt sha256=%s\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \"$(openssl x509 -in server.crt.new -noout -serial | sed 's/^serial=//')" \"$SERVER_LAN_IP" \"$(openssl x509 -in server.crt.new -noout -fingerprint -sha256 | sed 's/^sha256 Fingerprint=//')" \>> "$CA_USB/issued-leaves.log"Do not proceed if
openssl verifyfails — the existing cert is still serving. -
Unplug the CA USB stick, then install the leaf and its key:
sudo install -o sapot -g sapot -m 600 server.key.new /home/sapot/certs/server.keysudo install -o sapot -g sapot -m 644 server.crt.new /home/sapot/certs/server.crtsudo install -o sapot -g sapot -m 644 "$CA_USB/server_ca.pem" /home/sapot/certs/server_ca.pem -
Reload the TLS terminator:
sudo systemctl reload nginx# or, if serving TLS from gunicorn directly:sudo systemctl restart server-main-api
Verification. request-cert.sh verifies the leaf against the CA before installing it and refuses to publish one that fails, so a normal Path A run needs no separate check. On bundle installs, doctor.sh's certificate check independently confirms the live cert is unexpired, matches its key, chains to server_ca.pem, and carries both server.sapot.lan and the detected LAN IP as SANs. To inspect a cert by hand:
openssl x509 -in server.crt -noout -text | grep -A1 "Subject Alternative Name"
openssl verify -CAfile server_ca.pem server.crt
# expect: server.crt: OK
Known gap — server_ca.srl lost: signing uses -CAcreateserial -CAserial server_ca.srl. If server_ca.srl is lost, reconstruct the next serial from the highest recorded serial= value in issued-leaves.log on the CA USB stick. If that log is also gone, -CAcreateserial resets serial numbering from scratch, which risks a serial collision against leaves already issued and deployed in the field. This is a known manual-recovery gap, not something the tooling papers over: before resuming issuance, compare the recovered serial against any leaf certs you can still locate in the field.
Note — this is not the dev/CI cert flow. docker/gen-certs.sh is a separate dev-and-CI-only tool driven by docker-compose.yml's certgen service, backed by the throwaway CA in server/dev-ca/ (server.md). It can self-sign and is deliberately not shipped in deployment bundles. Never point it at the production CA stick, and never reuse a dev CA for a real deployment.
Verification (on server host, after deployment):
# Path A: $SAPOT_ROOT/shared/certs/server.crt Path B: /home/sapot/certs/server.crt
openssl x509 -in <cert-path> -noout -dates
# confirm notAfter reflects the new cert's expiry
echo | openssl s_client -connect <server-lan-ip>:443 2>/dev/null | openssl x509 -noout -subject -dates
# confirm the cert is being served and dates match
echo | openssl s_client -connect <server-lan-ip>:443 2>/dev/null \
| openssl x509 -noout -text | grep -A1 "Subject Alternative Name"
# expect: DNS:server.sapot.lan, IP:<server-lan-ip>, DNS:localhost
# a missing server.sapot.lan breaks every mobile preview/production build
On a docker-bundle install, sudo /opt/sapot/releases/current/scripts/doctor.sh checks the same things (expiry, key pairing, and both SANs) in one command.
Mobile app rebuild: The mobile app pins the CA certificate (not the leaf) — when you re-issue a new leaf from the same CA, old app builds continue to work without rebuilding (the new leaf will validate against the pinned CA). Only rebuild the app if the CA itself is rotated.
Rollback (server code deploy)
When: A server deploy introduces a regression and needs to be reverted.
- Identify the last known-good git tag/commit (see the repo-root
VERSIONING.md). - If the deploy included a DDL change, do not roll back code without also reverting the schema — check whether the new columns/tables the deploy added are still compatible with the old code (additive changes usually are; renames/type changes are not). If incompatible, restore the pre-deploy DB backup first.
- Redeploy the previous code version:
cd servergit checkout <previous-tag>source app/venv/bin/activate && pip install -r app/requirements.txtsudo systemctl restart server-main-api
- Confirm the regression is gone and file the incident for follow-up.
Verification: same health-check as the backup/restore procedure, plus manual confirmation the specific regression is resolved.
Disaster recovery — server hardware fails at incident site
When: The laptop/server hardware running the FastAPI server, MariaDB, and Redis fails or is destroyed mid-deployment.
Immediate impact: Mobile-to-mobile LAN messaging and calls continue to work — they are P2P and do not depend on the server (see system-overview.md). What breaks immediately: new logins/registration, cross-device sync, GPS streaming to rescuers, announcements, admin operations, and SMS fallback (GSM module depends on the same DB).
Recovery steps:
- If a spare host is available on-site: stand up the full stack fresh (see server.md, deployment/overview.md) and restore the most recent backup. If backups were only stored on the failed host, this step is impossible — see the note on off-host backup storage above.
- If no spare host is available: the LAN continues to function in degraded P2P-only mode. Do not attempt to route around this with a temporary unsecured server — bringing up a server without
DATABASE_URL/JWT_SECRET_KEY/CORS_ALLOWED_ORIGINSproperly configured re-opens the issues documented in the repo-rootSECURITY.md. - Once a replacement host is running, re-point the MikroTik router's DNS/DHCP (or the mobile app's configured server IP, if static) at the new host's address.
- Mobile devices with cached credentials will resume syncing automatically once the server is reachable at the expected address; devices requiring fresh login need the new server reachable first.
TODO (human input required): Confirm whether a pre-staged spare host/image is part of the standard field kit, and document the expected recovery time objective (RTO) for an incident deployment.