# Runbook: Open Dental — Rock-solid database + images backup (Windows) **Applies to:** The Windows machine hosting the Open Dental MySQL/MariaDB database (the "server"). **Goal:** A fully consistent, restorable backup of both halves of an Open Dental practice — the **database** and the **A-to-Z images folder** — following Open Dental and MySQL/MariaDB best practice, including cleanly **stopping the database during the copy and restarting it after**. ### What a complete Open Dental backup is (and isn't) Per Open Dental's own docs, a full backup is **two things — the database and the A-to-Z images folder** — and both must be captured together. A database backup **without** the images folder (or vice-versa) is not a usable restore. | Component | Default location | Back up? | Holds | |---|---|---|---| | MySQL/MariaDB **data directory** | `C:\mysql\data\` (contains the `opendental` DB) | **Yes — required** | All clinical/financial data | | **A-to-Z / OpenDentImages** folder | `C:\OpenDentImages\` | **Yes — required** | Scanned docs, images, attachments | | **`FreeDentalConfig.xml`** | Open Dental install dir | Nice-to-have | Connection / Choose-Database / AtoZ path config | | Open Dental **program files** | `…\Program Files\Open Dental\` | **No — reinstall** | The application itself | | Other office documents | (varies) | Per your policy | Non-Open-Dental files | **There is no separate "application backup."** Open Dental does **not** back up the program itself — on restore you **reinstall the matching Open Dental version** (and matching MySQL/MariaDB) and point it at the restored data. So the two "recovery aids" worth grabbing are just: a copy of **`FreeDentalConfig.xml`** and a record of the **exact versions** to reinstall. The script captures both automatically into the backup folder (`FreeDentalConfig.xml` + `MANIFEST.txt`) — nothing extra to run. **Placeholders:** | Placeholder | Meaning | |---|---| | `` | Name of the MySQL/MariaDB Windows service (e.g. `MySQL`, `MySQL57`, `MariaDB`) | | `` | MySQL data directory — the folder **containing** the `opendental` subfolder (commonly `C:\mysql\data`) | | `` | A-to-Z images folder (commonly `C:\OpenDentImages`) | | `` | Backup destination — separate physical disk or UNC path, ideally replicated off-site | | `` / `` | A MySQL account for `mysqldump` (supplemental method) — from the password manager, never committed | --- ## Why "cold copy" is the rock-solid method Open Dental databases run on **MyISAM or InnoDB**. For a *file-level* backup to be consistent: - A **hot copy** (copying the data directory while the service runs) can capture InnoDB mid-write → **corrupt, unrestorable** backup. Open Dental's built-in Backup tool and most "online" file backups **cannot even restore InnoDB**. - A **cold copy** — stop the service so it flushes and closes cleanly, copy, restart — is **consistent for both engines**. This is the gold-standard local backup. Two details that make or break a cold copy: 1. **Copy the *entire* data directory, not just `data\opendental\`.** InnoDB's shared tablespace and redo logs (`ibdata1`, `ib_logfile*`) live at the **root** of the data directory. Copy only the `opendental` subfolder and an InnoDB restore will fail. 2. **Verify the service actually stopped before copying.** If it won't stop, do **not** copy — you'd capture a live datadir. The script below does both, and **always restarts the service** (even if the copy fails), so the practice is never left down. --- ## 1. Pre-flight - Run **on the database server**, in an **elevated** PowerShell, **off-hours** — the copy causes downtime; Open Dental is unavailable on every workstation while the service is stopped. - Make sure **no one is in Open Dental** (fully closed on all workstations). - Have a `` on a **different physical disk** (or UNC share) with enough free space for the data directory **+** images. ## 2. Run the cold backup (primary method) From an **elevated** PowerShell on the server: ``` irm https://gitea.ivangodwin.com/ops/rb/raw/branch/main/scripts/od-db-backup.ps1 | iex ``` It will: 1. Auto-detect the ``, ``, and `` (prompting to confirm/override). 2. Report sizes and destination free space, then confirm before doing anything. 3. **Stop `` and verify it reached *Stopped*** — aborting the copy if it doesn't. 4. `robocopy` the **whole** data directory to `\od-backup-\data`, then the images to `…\OpenDentImages`. 5. **Restart ``** in a `finally` block — this runs even if the copy fails or is interrupted. 6. Capture recovery aids (best-effort, outside the downtime window): a copy of **`FreeDentalConfig.xml`** and the exact **Open Dental + MySQL/MariaDB versions**. 7. Write `MANIFEST.txt` and `backup.log` into the backup folder. ### Manual equivalent (offline / if you can't fetch the script) Elevated PowerShell, no one in Open Dental: ``` net stop Get-Service # confirm Status = Stopped BEFORE copying robocopy "" "\od-backup\data" /E /COPY:DAT /R:2 /W:5 robocopy "" "\od-backup\OpenDentImages" /E /COPY:DAT /R:2 /W:5 net start Get-Service # confirm Status = Running ``` > Copy `` itself (the parent of `opendental`), **not** `\opendental` — you need `ibdata1` / `ib_logfile*` at the datadir root for InnoDB. If `net start` fails, start it immediately: the practice cannot work until the DB is back up. ## 3. Supplemental logical backup (mysqldump) A cold copy is engine-perfect but physical — a portable **logical** dump is a valuable second line (survives binary corruption, restores to any server/version). It runs **while the service is up**, so schedule it separately (e.g. mid-day incremental in addition to the nightly cold copy). Slight slowness while it runs. ``` mysqldump -u -p --single-transaction --quick --max-allowed-packet=1024M --default-character-set=utf8 --routines --events opendental > "\opendental-.sql" ``` - `--single-transaction` gives a consistent snapshot of **InnoDB** without locking the practice out. (For **MyISAM**, that flag does not guarantee consistency — use the cold copy as the source of truth.) - Compress the `.sql` afterward; it shrinks dramatically. - The dump does **not** include the images folder — always pair it with an `` copy. ## 4. Schedule it (daily minimum) Open Dental's floor is **at least one backup per day**; combine an automated nightly job with an off-site copy. Register the cold backup as a nightly **Task Scheduler** job (runs off-hours, as SYSTEM/admin). Because `irm | iex` prompts interactively, schedule a **local copy** of the script with the paths baked in (keep that filled-in copy in the **private tier**, not here), e.g.: ``` schtasks /Create /TN "OD Nightly Cold Backup" /TR "powershell -NoProfile -ExecutionPolicy Bypass -File C:\ops\od-db-backup-local.ps1" /SC DAILY /ST 23:30 /RU SYSTEM /RL HIGHEST ``` ## 5. Verify — a backup you haven't restored is a guess - **Test-restore** to an **isolated** machine periodically (matching Open Dental + MySQL/MariaDB versions): `net stop`, rename the existing `opendental` folder aside, drop in the backup's `data\` contents, `net start`, launch Open Dental, spot-check patients/images. Full step-by-step with a health checklist: **[`od-backup-verify.md`](od-backup-verify.md)** (isolated Hyper-V test-restore). - **Never restore over a live production database** — data loss is irreversible. - Confirm the nightly job is actually producing dated folders and that they leave the building (off-site / immutable copy) — ransomware that reaches the server will reach on-line backups too. ## 6. Retention & off-site (3-2-1) - **3** copies, **2** media, **1** off-site. The `` disk alone is not a backup strategy. - Keep several daily generations plus weekly/monthly rollups; prune old `od-backup-` folders on a schedule. - These files contain **PHI** — encrypt at rest and in transit; restrict access. HIPAA applies. ### Point off-site tools at ``, not at the live database Any off-site/cloud replication (Duplicati → Backblaze B2, Veeam, `rclone`, Wasabi, etc.) must use **`` as its source** — it backs up *this backup*. It must **not** re-run the stop/copy against the live ``. - The `od-backup-` folders under `` are already a **consistent, cold copy** produced with the service cleanly stopped. Copying *them* off-site is a safe file copy — no service stop, no downtime, no consistency risk. - Never let a naive file-sync tool crawl the live `C:\mysql\data` directly. A hot copy of a running InnoDB datadir is **corrupt and unrestorable** — the exact failure this runbook exists to avoid. Off-site tools have no idea they need to stop the service first; that's *this* script's job, done once, up front. - **Sequence:** this cold backup runs first (nightly, off-hours) → the off-site job runs **after** it completes, sourcing ``. Stagger the schedules (e.g. cold backup 23:30, off-site upload 01:00) so the upload never reads a half-written `od-backup-` folder. If your tool supports it, exclude any in-progress/partial folder or upload only completed timestamps. - Let the off-site tool own its **own** encryption + retention on top — B2/Wasabi object lock or Duplicati's immutability/versioning gives you the ransomware-resistant, off-site copy of the 3-2-1 rule. --- ## Security note Backup media and dumps hold the **entire practice's PHI**. Treat them as the crown jewels: encrypt the destination, lock down share permissions, and keep at least one copy **off-line/immutable** so ransomware can't encrypt your backups along with production. Don't store `` in the scheduled command line — use a MySQL option file / limited account. Note that the captured `FreeDentalConfig.xml` stores the MySQL password **obfuscated but reversible** on **direct-connection** setups. The backup already contains all PHI so this doesn't change the requirement — but it's one more reason the destination must be encrypted and access-controlled. (Middle Tier setups don't put DB credentials in that file — see `od-cfg-persist.md`.) ## References - Open Dental manual — Backups (overview): - Open Dental manual — Manual Backups: - Open Dental manual — Backup Tool: - Open Dental manual — FreeDentalConfig.xml: - Open Dental — InnoDB (backup implications): - Open Dental manual — MySQL Data Directory Management: - MySQL — `mysqldump`, `--single-transaction`: