Skip to content

Backup & Restore

A complete HEMMS backup has two parts: the Postgres database (records, configuration, audit trail) and the Odoo filestore (attachments — scanned ปจป. PDFs, signed service contracts, equipment photos). If you only back up one of the two, you cannot fully restore.

This page covers what make backup-private actually does, how to add the filestore to that backup, how to restore, and the cadence Trinity Roots recommends for production deployments.

What gets backed up

Asset Lives in What's lost if missing
Database Postgres volume (db_private_data / db_gov_data) All records: equipment, requests, contracts, audit log, users, permissions
Filestore Odoo volume (odoo_private_data / odoo_gov_data), path /var/lib/odoo/.local/share/Odoo/filestore/<dbname>/ Every attachment: signed PDFs, photos, imported XLSX files. The DB references these by hash — without the filestore, attachment downloads return 404
addons/ source Git repo on disk Custom code — already in version control on GitHub. Not part of the backup.
config/*.conf Repo on disk Admin password, DB credentials — already in version control. Not part of the backup.

The Makefile backup target captures the database only

make backup-private runs pg_dump and does not copy the filestore. That's fine for short-lived demo databases (the seed demo data fills the filestore on install). It is not enough for production. See Adding the filestore to the backup below.

Database backup — make backup-private

The headline command:

make backup-private    # or make backup-gov

This invokes scripts/backup_db.sh which runs pg_dump inside the Postgres container and pipes the output through gzip:

docker exec hemms_db_private pg_dump -U odoo_private hemms_private \
    | gzip > ./backups/private_<timestamp>.sql.gz

Output landing point:

./backups/private_YYYYMMDD_HHMMSS.sql.gz
./backups/gov_YYYYMMDD_HHMMSS.sql.gz

The script creates ./backups/ if it doesn't exist. Files are timestamped to the second, so two backups taken in the same minute won't collide.

Sandbox Container DB user DB name Backup filename
private hemms_db_private odoo_private hemms_private private_<ts>.sql.gz
gov hemms_db_gov odoo_gov hemms_gov gov_<ts>.sql.gz

Adding the filestore to the backup

For production, wrap make backup-private with a filestore copy:

#!/usr/bin/env bash
set -euo pipefail
TS=$(date +%Y%m%d_%H%M%S)
SANDBOX=private
DB=hemms_private
CONTAINER_ODOO=hemms_odoo_private

# 1. DB dump (via the Makefile target)
make backup-${SANDBOX}

# 2. Filestore tarball — read from inside the Odoo container
docker exec ${CONTAINER_ODOO} tar -czf - \
    -C /var/lib/odoo/.local/share/Odoo/filestore ${DB} \
    > ./backups/${SANDBOX}_filestore_${TS}.tar.gz

echo "==> Filestore backup complete: ./backups/${SANDBOX}_filestore_${TS}.tar.gz"

Now each backup is two files with matching timestamps:

./backups/private_20260523_020005.sql.gz
./backups/private_filestore_20260523_020005.tar.gz

Keep them together — they must be restored as a pair.

Restore procedure

A clean restore drops the existing database, recreates it, replays the dump, and unpacks the filestore.

Restore is destructive

Restore replaces the live database. Take a fresh backup first (make backup-private) so you can roll forward if the restore goes wrong. Stop Odoo before restoring — concurrent writes during restore will corrupt the database.

Step 1 — Stop Odoo, keep Postgres up

docker stop hemms_odoo_private
# leave hemms_db_private running

Step 2 — Drop and recreate the database

docker exec -it hemms_db_private psql -U odoo_private -d postgres \
    -c "DROP DATABASE IF EXISTS hemms_private;"
docker exec -it hemms_db_private psql -U odoo_private -d postgres \
    -c "CREATE DATABASE hemms_private OWNER odoo_private;"

Step 3 — Replay the SQL dump

gunzip -c ./backups/private_20260523_020005.sql.gz \
    | docker exec -i hemms_db_private psql -U odoo_private -d hemms_private

This restores every row but not the filestore.

Step 4 — Restore the filestore

# Wipe the existing filestore for this DB first
docker exec hemms_odoo_private rm -rf \
    /var/lib/odoo/.local/share/Odoo/filestore/hemms_private

# Unpack the tarball into the same parent directory
docker exec -i hemms_odoo_private tar -xzf - \
    -C /var/lib/odoo/.local/share/Odoo/filestore \
    < ./backups/private_filestore_20260523_020005.tar.gz

Step 5 — Start Odoo, verify

docker start hemms_odoo_private
make logs-private    # watch for "Modules loaded."

Open the UI, log in, and spot-check:

  1. Maintenance → Equipment — record counts match the source DB.
  2. Open a signed service contract and click the attachment — it should download (proves the filestore is intact).
  3. HA Report → Annual Reports — open last year's report and verify the PDF renders.

If attachments 404, the filestore restore missed a step — repeat step 4.

When What
Nightly DB + filestore backup. Cron the wrapped script. Retain 14 daily backups.
Weekly Promote one nightly to weekly. Retain 8 weekly backups (≈ 2 months).
Monthly Promote one weekly to monthly. Retain 12 monthly backups (≈ 1 year).
Before every module install / upgrade make backup-private + filestore tarball. Manual, on-demand.
Before every restore test Snapshot the live state first.
Annually — disaster recovery drill Full restore on a non-prod host. Measure RTO. Document the result.

Backups should be copied off the Docker host to object storage (S3, GCS, or an NFS share on a different machine). A backup on the same disk as the live DB does not survive disk failure.

Disaster recovery drill

Schedule annually. The drill answers two questions: Does the restore procedure actually work? and How long does it take?

  1. Pick a non-prod host (a laptop or a cheap VM).
  2. Clone the repo, make up-private, but do not make install-private. You want an empty DB to restore into.
  3. Copy the most recent off-site backup pair to ./backups/.
  4. Run the restore procedure above end-to-end. Time it.
  5. Verify the spot-checks in Step 5.
  6. Record:
    • RTO (recovery time objective) — how long the restore took
    • RPO (recovery point objective) — gap between last backup and the drill time
    • Any steps that failed or surprised you — feed back into this doc

Keep the drill report with your HA Thailand documentation. HA reviewers look for evidence that disaster recovery is rehearsed, not just documented.

Common pitfalls

pg_dump version mismatch

If you upgrade Postgres on the host or in the container, an old dump file can still be replayed — but a new dump file cannot be replayed on an older Postgres. Match the dump-time and restore-time major versions (currently postgres:15).

Filestore disk usage grows silently

Every signed PDF, photo, and imported XLSX lives in the filestore. Monitor docker exec hemms_odoo_private du -sh /var/lib/odoo/.local/share/Odoo/filestore/ monthly. If a hospital uploads 4K equipment photos, the filestore can grow faster than the DB.

Encrypted backups for off-site storage

Patient-adjacent data sits inside HEMMS (equipment serial numbers linked to wards, attachments). Encrypt backups at rest before shipping them off the host — gpg --symmetric backup.sql.gz is the minimum.