All notable changes to pi2s3 are documented here.
-
Telegram notifications, and one notifier for every script (
lib/notify.sh) —notify_send()sends via Telegram whenTG_BOT_TOKEN+TG_CHAT_IDare set, and via ntfy whenNTFY_URLis set, so existing ntfy installs are unchanged. Each script previously carried its ownntfy_send()copy, all three of which began[[ -z "${NTFY_URL:-}" ]] && return 0. On a host whose config had moved to Telegram that line discarded every notification for 38 consecutive nights (2026-07-03 to 2026-08-09): the nightly image backups ran and verified perfectly the whole time, so the only symptom was silence, which is also what a healthy night sounds like.ntfy_send()is kept as an alias, so the ~20 existing call sites are untouched.lib/notify-test.shcovers transport selection with curl stubbed. -
Restore-readiness on prepared SD cards (
extras/firstboot/prepare-sd.sh) —firstrun.shnow provisions, in both flash and inject modes, passwordless sudo (/etc/sudoers.d, visudo-validated) for the provisioned user and tty1 console autologin. Without this, an unattended restore that reboots into a freshly-prepared card strands: the restore can't run privileged commands (no sudo) and a headless box appears "stuck at a login screen". Found during a live failover restore. -
Token-tunnel post-restore template (
extras/post-restore-cloudflared-token-example.sh) — preserves a modern token-based cloudflared tunnel (cloudflared tunnel run --token …, noconfig.yml) across a restore by copying the live systemd unit into the restored image and removing a conflictingconfig.yml. The existing credentials-file example (post-restore-example.sh) does not cover token tunnels, so a restore would silently boot the source image's tunnel and take the target's hostnames (incl. its SSH hostname) dark.
- A missing notifier no longer disables the container safety net (
pi2s3-post-backup-check.sh) — it answered an unsetNTFY_URLwithexit 1, so on the host above it did not run for the same 38 nights. That script exists to restart containers the backup left stopped; refusing to run because nobody can be told is the opposite of a safety net. It now warns and does its job. - An unconfigured or undeliverable notifier is loud (
lib/notify.sh) — no destination logs "alerts are being DISCARDED, including failures" once per run and still records each undelivered title; a configured transport that fails all three attempts logsFAILED after 3 attemptsand returns non-zero. Previously both were indistinguishable from success. --upgradeinstalls a missing watchdog cron instead of silently skipping it (install.sh) — the stale-check and post-backup-check blocks only refreshed an entry that already existed, so a host installed before those features gained them never did, while--upgradereported success. andrew-pi-5 had no--stale-checkentry at all, which is why two nights with no backup (the machine was down at 02:00 on 2026-08-03 and 2026-08-05) passed unremarked. Guarded on the main backup cron being present, so--upgradeafter--uninstalldoes not resurrect watchdogs.- The heartbeat reaches the configured transport (
pi2s3-heartbeat.sh) — it curledNTFY_URLdirectly, so on a Telegram host it posted to an empty URL every morning and failed silently. That is the one message whose entire job is to prove the alerting path still works.TG_HEARTBEAT_ENABLEDis the current flag;NTFY_HEARTBEAT_ENABLEDis still honoured. - The installer reports the transport it will actually use (
install.sh) — it warned aboutNTFY_URLalone, so a correctly configured Telegram host was told its backups would be silent, and a host with neither transport got the same message.
website/restoreno longer aborts on a dirty~/pi2s3— a local patch or diverged tree previously madegit pull --ff-onlyhard-fail mid-restore. The bootstrap now continues with the installed version and prints how to update; setPI2S3_FORCE_UPDATE=1to auto-stash and pull (never stashes silently, to avoid hiding an intentional local fix).
- PostgreSQL zero-downtime quiesce (
pi-image-backup.sh,lib/containers.sh) — pi2s3 now backs up PostgreSQL with no downtime. PostgreSQL has noFLUSH TABLES WITH READ LOCKequivalent and needs none for a single-volume block image: the whole data directory (includingpg_wal) lands in the same partclone image, sodb_lock_postgres()issues aCHECKPOINTto flush dirty buffers and then images the live filesystem. Writes are never blocked. On restore, PostgreSQL replays WAL exactly as it would after a power loss and comes up consistent — the documented method for filesystem snapshots that capture the entire data directory. Container installs and native peer-auth setups need no password;DB_PG_USERsets the superuser (defaultpostgres). - Native (non-Docker) database detection (
lib/containers.sh) —DB_CONTAINER="auto"now detects a database running natively on the host (mariadbd/mysqld/postgresprocesses), not just in Docker. Native MySQL/MariaDB previously fell back to a stop-the-service downtime; it now uses the zero-downtime path (setDB_ROOT_PASSWORD— there is no container env to read it from). Native PostgreSQL with peer auth needs no password. DB_ENGINEandDB_PG_USERconfig (config.env.example) —DB_ENGINE(auto|mysql|mariadb|postgres) forces the engine for an explicit native install where auto-detection can't see a container.DB_PG_USERis the PostgreSQL superuser used forCHECKPOINT.AGENTS.md+pi2s3.com/llms.txt— agent-facing instructions so an AI assistant (e.g. Claude) pointed at the repo or the site can install pi2s3 and run a backup unattended ("backup my site with pi2s3").--db-checkdiagnostic mode (pi-image-backup.sh) — reports DB detection (engine, container/native), connecting user, version, and whether the read-only quiesce actually engages, then exits without imaging. Briefly togglesread_onlyand restores it (zero downtime). Use it to confirm a backup will be zero-downtime before relying on it.
- Container DB client fell back to
mysql(pi-image-backup.sh) —db_exechardcoded themariadbclient for the Docker path. MySQL images (and MariaDB before 10.5) ship only themysqlbinary, sodocker exec … mariadbfailed with "executable not found", the read-only quiesce silently no-op'd, and the backup fell back toSTOP_DOCKER— causing avoidable downtime. Now triesmariadbthenmysql, mirroring the native branch. (Found in production: a MySQL 8.0 analytics container was auto-detected and the quiesce silently failed.) - Silent quiesce failures now logged (
pi-image-backup.sh) —db_execcaptured stderr to/dev/null, so a failed quiesce gave no cause. It now captures stderr into_DB_LAST_ERRanddb_lock_mysqllogs the real MariaDB/MySQL error in its fallback branch.
- MySQL/MariaDB quiesce switched from
FLUSH TABLES WITH READ LOCKtoSET GLOBAL read_only(pi-image-backup.sh) — gentler than holding a global read lock and needs no keepalive connection.SET GLOBAL read_only=ON(plussuper_read_only=ONon MySQL, best-effort — MariaDB has no such variable) blocks application writes during the sub-10-second flush window while reads and cached pages keep serving; read-write is restored before imaging. Safety: the priorread_onlystate is read first, so a server that is already read-only (e.g. a replica) is left untouched; a sentinel file lets the next backup recover a stale read-only state left by a hard-killed run, and the on-exit trap restores read-write on any normal or error exit. - Engine-aware quiesce dispatch (
pi-image-backup.sh) —db_lock()now resolves the engine and location (container or native) viadb_resolve_target()and dispatches todb_lock_mysql()ordb_lock_postgres(). Falls back toSTOP_DOCKERonly when no supported database is detected or quiesce fails.
extras/diagnose-restore.sh— 10-section diagnostic script. Covers: (1) power/voltage with decoded throttle state, per-rail voltages, CPU temp, dmesg undervoltage event count; (2) hardware — CPU, memory, NVMe presence + SMART, EEPROM boot order, watchdog device; (3) restore log completeness — per-log pass/fail, monitor CSV with undervoltage-per-interval analysis, download speed; (4) WiFi — saved connections, active SSID + signal, password special-character encoding check; (5) corporate proxy/firewall — env vars,/etc/environment, iptables/nftables output rules; (6) internet + AWS — 10-ping packet loss to gateway/8.8.8.8/1.1.1.1, HTTPS TCP checks, S3 bucket access, STS identity; (7) active restore processes + taskset affinity; (8) boot config — SD card and NVMe cmdline.txt with PARTUUID cross-check (verifiesroot=PARTUUID=matches an actual attached block device, prints exactsedfix command if not); (9) recent kernel messages; (10) S3 manifest JSON validation — downloads the latest manifest, validates JSON withjq, detects malformed fields (the silentdd-vs-partclone fallback bug), shows detectedbackup_type, and prints thesedauto-repair command.extras/recover-sd-boot.sh— Mac-side recovery script for a Pi showing solid red LED (won't boot after restore). Auto-detects the SD card at/Volumes/bootfs; shows and diagnosescmdline.txt; restores fromcmdline.txt.bakif the automatic backup is present; otherwise prints step-by-step options (USB NVMe adapter, fresh flash, manual PARTUUID lookup).cmdline.txt.bakautomatic backup (extras/post-restore-nvme-boot.sh) — before editing/boot/firmware/cmdline.txt, the original is backed up tocmdline.txt.bak. Used byrecover-sd-boot.shto restore a working boot when the PARTUUID write fails.rootdelay=5inpost-restore-nvme-boot.sh— added to/boot/firmware/cmdline.txtif not present; gives the NVMe PCIe link time to enumerate before the kernel searches for the root partition.- Hardware watchdog (
pi-image-restore.sh) — opens/dev/watchdog(with/dev/watchdog0fallback for Pi OS Trixie where systemd holds the primary device) and kicks it every 30 s during restore. On hang, the watchdog fires a hardware reboot. - Background system monitor (
pi-image-restore.sh) — samples network bytes, CPU idle %, free memory, and throttle state every 10 s during restore. Saves CSV to/var/log/pi2s3-restore-monitor-TIMESTAMP.log.diagnose-restore.shparses this to count undervoltage intervals and compute average download speed. - Persistent restore log (
pi-image-restore.sh) —exec > >(tee /var/log/pi2s3-restore-TIMESTAMP.log)at startup; log survives reboots and is analysed bydiagnose-restore.sh. - Restore pinned to 1 CPU (
pi-image-restore.sh) —tasksetpins the entire restore pipeline to the last CPU; OS, SSH, and network stack keep all remaining cores. Reduces sustained power draw, preventing undervoltage on marginal PSUs. CLONE_SUFFIXvariable (extras/post-restore-nvme-boot.sh) — configurable hostname suffix when cloning a Pi. Default is-2(e.g.andrewninja-pi-5→andrewninja-pi-5-2). SetNEW_HOSTNAMEfor an exact name orCLONE_SUFFIX=-qafor a suffix override. Prevents hostname conflicts without manual post-boot renaming.config.envauto-exported to subprocesses (pi-image-restore.sh) —set -a; source config.env; set +aexports every variable (includingNEW_HOSTNAME) so--post-restorescripts inherit them without any extra plumbing.
- Undervoltage abort too aggressive (
pi-image-restore.sh) —0x50005(undervoltage + throttle) is a common boot-time transient that clears within seconds. Aborting immediately blocked restores on healthy PSUs with a brief startup dip. Changed to: warn with banner → sleep 10 s → re-check; only abort if still undervolted after 10 s. - Manifest JSON malformation causes silent
ddfallback (pi-image-restore.sh) — a"extra_device": ,field in the manifest (produced bypi-image-backup.shwhenBACKUP_EXTRA_DEVICEis unset) causedjqto fail silently,BACKUP_TYPEdefaulted to"dd", and the partclone backup was streamed raw to the device producing an unbootable NVMe. Fixed: auto-repair withsed 's/:\s*,/: null,/g'applied before all parsing; grep regex fallback added toget_manifest_field(). - IRQ pinning glob (
pi-image-restore.sh) — glob was too broad; narrowed to only CPU-bound IRQs. - Watchdog device held by systemd (
pi-image-restore.sh) — Pi OS Trixie's systemd holds/dev/watchdogexclusively. Addedexec 3>probe loop that falls back to/dev/watchdog0automatically.
--rate-limit <speed>flag (pi-image-restore.sh) — caps the uncompressed byte rate intopartclone(applied aftergunzip, directly controlling NVMe write throughput). Prevents PCIe watchdog resets on Pi 5 + NVMe combinations that crash under sustained writes. Example:--rate-limit 10m= 10 MB/s to the NVMe. Requirespv.extras/post-restore-nvme-boot.sh— post-restore script that wires up NVMe as the boot target without any manual steps: (1) swaps the original Pi's SD card PARTUUID in/etc/fstabwith the new Pi's SD PARTUUID; (2) updates/boot/firmware/cmdline.txtroot=to point at the restored NVMe root partition. PassNEW_HOSTNAME=<name>to rename the clone in the same step. Full error handling and per-step logging.extras/cloud-init/— Pi OS Bookworm cloud-init templates for a DR/QA Pi (user-data,network-config,meta-data). Enables a factory-fresh Pi to SSH-ready in ~1 min with all pi2s3 dependencies installed and the repo cloned.extras/DR-quickstart.md— end-to-end DR runbook: flash SD → cloud-init → AWS credentials → restore → NVMe boot → Cloudflare tunnel.- ionice + nice on partclone pipeline (
pi-image-restore.sh) —ionice -c 3 nice -n 19prevents the restore from starving the SSH session's network stack on memory-constrained machines. - fsck after every restored ext partition (
pi-image-restore.sh) — runse2fsck -f -yon each ext2/3/4 partition immediately afterpartclonecompletes, clearing the dirty-journal state left by a live backup. Exit codes 0/1 = clean, 2 = warn, 4+ = error logged.
jqnot listed as a required dependency (pi-image-restore.sh) —get_manifest_field()usesjqto parsebackup_typefrom the manifest. Withoutjq, it returned empty andBACKUP_TYPEsilently fell back to"dd", causing every partclone-format restore to write a single raw partition to the whole device with no partition table. Added explicitcommand -v jq || diecheck and addedjqto the cloud-init package list.--rate-limitapplied to compressed stream (pi-image-restore.sh) — the rate-limitingpv -Lwas positioned beforegunzip, limiting the compressed byte rate. After expansion (~1.3–1.4× for typical ext4 data), the actual NVMe write rate exceeded the specified limit by 30–40%. Moved the rate-limitingpvto aftergunzipfor direct write-rate control; the progress-displaypvremains beforegunzip.- Invalid JSON in manifest when
extra_deviceis unset (pi-image-backup.sh) — the manifest heredoc produced"extra_device": ,whenBACKUP_EXTRA_DEVICEwas unset, failingpython3/jqJSON parsing on every standard (single-device) backup. Pre-computeEXTRA_DEVICE_JSONwith anullfallback before the heredoc. fstypenot passed to partition restore loop (pi-image-restore.sh) — the Python manifest parser only extractedname,tool,key,compressed_bytes. Addedfstypeso the new post-restore fsck can identify which partitions are ext2/3/4.
- SUDO_USER credential forwarding (
pi-image-restore.sh) — when run assudo, AWS CLI looked in/root/.aws/instead of the real user's home directory. Now detectsSUDO_USERviagetent passwdand setsAWS_CONFIG_FILE/AWS_SHARED_CREDENTIALS_FILEto the real user's paths. jqadded to cloud-init package list (extras/cloud-init/user-data) —jqis now installed alongsidepartclone pigz pv python3 git awscliso the restore script works out of the box on a freshly cloud-init-provisioned Pi.- Next-steps section (
pi-image-restore.sh) — updated to distinguish NVMe-on-SD-card boot setups from standalone device restores, and to referencepost-restore-nvme-boot.sh.
breakin resize function (pi-image-restore.sh) —breakwith no enclosing loop skipped the fsck abort guard and allowedresize2fsto run on a filesystem with uncorrectable errors. Replaced withreturn 1.- Spurious
cat |before heredoc (install.sh) —cat | sudo tee file <<HEREDOCmadecatread from the terminal whileteeread from the heredoc. Removed thecat |. A && ok || die/warnpatterns (install.sh,test-recovery.sh,website/restore) — the||branch could fire even on success if the intermediate command returned non-zero. All nine occurrences rewritten as explicitif/else.- Startup error messages going to stdout (
pi-image-backup.sh,pi-image-restore.sh,extras/fleet-deploy.sh) — fatal early-exit errors now correctly go to stderr. - Dead variable
VERIFY_DATE_FOR_VERIFY(pi-image-restore.sh) — removed unused declaration. - Unused
REPO_DIR(extras/build-recovery-usb.sh,extras/build-netboot-image.sh) — removed unused variable (left over from earlier draft). - Unused
SCRIPT_DIR(extras/build-recovery-usb.sh,extras/build-netboot-image.sh) — afterREPO_DIRwas removed,SCRIPT_DIRalso became unused and was removed. ls *.imgfor filename assignment (extras/build-recovery-usb.sh,extras/build-netboot-image.sh) — replaced withfind -maxdepth 1 -name '*.img'to handle filenames with spaces.- Hardcoded absolute paths (
deploy-pi.sh) —PI_KEY,PI_LOCAL,PI_CF_HOST,PI_CF_USERnow default to sensible values and can be overridden via environment variables. - Missing
set -eexplanation (extras/cf-tunnel-watchdog.sh,extras/fpm-saturation-monitor.sh) — added comment explaining why-eis intentionally omitted (both scripts must survive partial failures and continue recovery/monitoring). - SC2015 in fleet-deploy arg parser (
extras/fleet-deploy.sh) —[[ -z ... ]] && x="$1" || { error; exit }rewritten asif/else. - 40-line DONE comment block (
pi-image-backup.sh) — removed completed-feature notes from script header; history is in CHANGELOG. - Boot firmware compressed size not logged (
pi-image-backup.sh) —FW_COMPRESSED_HUMANwas computed but never used; now logged alongside SHA256. - Arithmetic
$on array index (pi-image-backup.sh) — removed unnecessary$on array subscript inside$((...)). trapdouble-quote SC2064 (extras/setup-netboot.sh) — addedshellcheck disablecomment explaining expand-at-set-time is intentional.sedfor indentation (extras/setup-netboot.sh) — replacedecho | sed 's/^/ /'with a plainwhile readloop.- SC1083 false positive on
@{u}(push.sh) — addedshellcheck disablecomment;@{u}is a git upstream refspec, not a bash brace expansion.
QUALITY-TODO.md— full code quality analysis report (21 findings) with per-script health scores. All items now resolved.
--post-restore <script>flag (pi-image-restore.sh) — after a full partclone restore completes, mounts the restored root partition read-write, exportsRESTORE_ROOT, and runs the user-supplied script. Enables restoring to a second Pi and immediately customising it (hostname, Cloudflare tunnel credentials,.envvariables, SSH host keys) before the first boot. Template atextras/post-restore-example.sh.- Recovery USB image builder (
extras/build-recovery-usb.sh) — builds a bootable Raspberry Pi OS Lite ARM64 image withpartclone,pigz,pv, AWS CLI v2, and the pi2s3 repo pre-installed. On first boot the Pi auto-logs in, prompts for S3 bucket and AWS credentials if not yet configured, and launches the restore wizard. Supports x86_64 build hosts viaqemu-user-static. - Recovery launcher (
extras/recovery-launcher.sh) — first-boot restore launcher used by the recovery USB image. Creates a minimalconfig.envfrom interactive prompts, runsaws configure, then hands off topi-image-restore.sh. - GitHub Actions: Build Recovery USB Image (
.github/workflows/release-recovery-usb.yml) — manualworkflow_dispatchto build the recovery USB image and publish it as a GitHub Release taggedrecovery-usb/YYYY-MM-DD. - Pi 5 HTTP netboot (
extras/setup-netboot.sh) — configures Pi 5 EEPROM boot order to include HTTP boot (BOOT_ORDERentry7) pointing atboot.pi2s3.com. Modes:(no args)adds HTTP as fallback after NVMe;--forcesets HTTP first for immediate recovery;--disableremoves HTTP;--showprints current EEPROM config. - Netboot image builder (
extras/build-netboot-image.sh) — extracts the Pi kernel from Pi OS Lite, builds a minimal initramfs (Pi OS base + partclone + AWS CLI + pi2s3), and writesconfig.txtandcmdline.txt. Optionally uploads to S3 with--upload. - GitHub Actions: Build Netboot Image (
.github/workflows/release-netboot.yml) — manual workflow to build netboot boot files and upload to S3, or save as a GitHub Actions artifact. - Terraform: boot.pi2s3.com (
extras/terraform/boot-infrastructure/) — creates a private S3 bucket, CloudFront OAC, CloudFront distribution withviewer_protocol_policy = allow-all(required for Pi HTTP boot), ACM certificate (us-east-1, DNS validation), and IAM userpi2s3-netboot-ciwith write-only access for CI uploads. Outputs ACM validation CNAMEs for Cloudflare and GitHub Actions secrets.README.mdincludes full manual AWS Console walkthrough as an alternative to Terraform. - Fleet deployment (
extras/fleet-deploy.sh) — reads a CSV manifest of Pis (name,host,date,device,post_restore_script), SSHes into each recovery-mode Pi, copiesconfig.envand the per-Pi post-restore script, then runspi-image-restore.shnon-interactively. Supports--parallel(all Pis simultaneously),--dry-run,--only <name>,--no-resize. Per-Pi logs saved tofleet-deploy-logs-<timestamp>/. Summary table on completion. - Fleet example (
extras/fleet-example/) — examplefleet.csvmanifest andpost-restore/classroom.shtemplate that auto-derives the hostname from the last octet of the Pi's IP address and clears SSH host keys for per-Pi uniqueness.
- CI syntax check —
.github/workflows/ci.ymlwas hardcoded to checkcf-tunnel-watchdog.shat the repo root. After the file moved toextras/, CI failed with "No such file or directory". Fixed by replacing the static list with dynamic grep-based discovery: any.shfile with a bash shebang anywhere in the repo is checked automatically. release-recovery-usb.ymlparse error — invalid GitHub Actions expression${{ steps.image.outputs.name %.xz }}used bash parameter expansion syntax inside${{ }}, causing "No jobs were run". Fixed by computingname_img(.xz-stripped filename) as a separate step output.
- Docs — README new sections:
--post-restore, Recovery USB, HTTP netboot, Fleet deployment. RECOVERY.md: "faster alternatives" note at Step 2 pointing to recovery USB and netboot. Website: new "Go further" section with four feature cards; restore section updated with three recovery-mode options.
- One-liner installer (
curl -sL pi2s3.com/install | bash) — bootstrap script atpi2s3.com/installdetects Pi model and architecture, installs git if missing, clones the repo (or pulls latest if already installed), and hands off toinstall.sh. Enables zero-prerequisite install from any Pi with internet access. - Bucket auto-create —
install.shnow distinguishesNoSuchBucketerrors from credential errors. If the bucket doesn't exist, offers to create it withaws s3 mb(default: yes). Handles theLocationConstraintrequirement for all regions exceptus-east-1. - First backup prompt — after a successful dry-run,
install.shasks "Run a real backup now? [Y/n]". Removes the need to remember--forceafter install. --iam-policyflag —bash install.sh --iam-policyprints the minimum IAM policy with the bucket name substituted fromconfig.env. Shown as a hint whenever AWS access fails.iam-policy.json— policy file included in the repo. Minimum permissions:s3:CreateBucket,s3:ListBucket,s3:PutObject,s3:GetObject,s3:DeleteObject,s3:PutLifecycleConfiguration,s3:GetLifecycleConfiguration.- Support matrix — documented tested/expected/unsupported combinations of Pi hardware, storage, and OS in README.
- Shared library extraction (
lib/log.sh,lib/aws.sh,lib/containers.sh) — eliminates duplicatelog()/die()/aws_cmd()/find_db_container()/read_container_db_password()definitions across scripts. main()guards — all scripts now use[[ "${BASH_SOURCE[0]}" == "${0}" ]] && main "$@"so they can be safely sourced for testing.push.sh— one-command push to GitHub + deploy to Pi with--no-deployoption.deploy-pi.sh— LAN-first SSH pattern (triesandrew-pi-5.localdirectly, falls back to Cloudflare tunnel).
- ntfy is now optional —
NTFY_URLis no longer required. Installer prompt accepts Enter to skip.ntfy_send()is a no-op whenNTFY_URLis empty.config.env.exampledefaults to blank. - 32-bit arch guard —
install.shnow exits immediately onarmv7l/armv6lwith a clear error message instead of downloading the wrong AWS CLI binary and failing mid-install. get_manifest_field()inpi-image-restore.sh— replaced fragilegrep -o | cutparsing withjq -r ".field // empty".
- 32 code-review findings across all scripts: unquoted variables, missing
pipefail, insecure temp files,localvariable leaks, errors written to stdout, missing dependency checks.
- Orphaned-lock false positives (self-referential PROCESSLIST query) —
fpm-saturation-monitor.shanddb_kill_orphaned_locks()detected their ownSELECT ... WHERE INFO LIKE '%pi2s3-lock%'queries as orphaned locks. MariaDB includes the querying connection itself ininformation_schema.PROCESSLIST, and the WHERE clause literal contains the string "pi2s3-lock", so each check matched itself. Result: ntfy "Orphaned backup lock killed" alerts fired every 30 min with incrementing connection IDs even when no backup was running. Fix: narrowed pattern to'%/* pi2s3-lock */%'(comment delimiters are present in the actual backup lock SQL but not in the WHERE clause text) and addedAND TIME > 5(real locks have been running for minutes; detection queries complete in milliseconds).
- FPM auto-restart (
FPM_AUTO_RESTART=true) —fpm-saturation-monitor.shnow automatically restarts the WordPress container when the saturation threshold is hit, instead of alerting and waiting for manual intervention. Runs on the host cron (not WP-Cron), so it fires even when PHP-FPM is fully exhausted.FPM_AUTO_RESTART=false(default) — alert only, no automatic actionFPM_AUTO_RESTART=true—docker restart <FPM_WP_CONTAINER>fires automatically after threshold, with ntfy confirmation- Before restarting, kills any orphaned
pi2s3-lockprocess from inside the DB container (see fix below) - Sends a separate
type=restartedcallback to the CloudScale Devtools plugin - Alert message updated: when auto-restart is on, says "Auto-restarting now" instead of "SSH and run: docker restart"
FPM_RESTART_COOLDOWN— minimum seconds between auto-restarts (default:1200/ 20 min). Prevents restart loops if saturation recurs immediately after a restart.- Pre-commit hook on Pi —
install.sh --upgradenow installs a.git/hooks/pre-committhat blocks direct commits on the Pi, with a message pointing to the Mac → deploy workflow. Refreshed on every deploy. config.env.example: full PHP-FPM saturation monitor section added (was missing entirely).- Website: new PHP-FPM saturation monitor section documenting auto-restart, orphaned lock detection, and plugin integration.
db_unlock()orphaned lock root cause — killing the host-sidedocker execwrapper process (kill $_DB_LOCK_PID) did not terminate themariadbclient running inside the container. The keepaliveSLEEP(86400)connection survived, holding FTWRL and causing "Orphaned backup lock" ntfy alerts after every successful backup. Fix:docker exec <container> pkill -9 -f pi2s3-lockis now called from inside the container before killing the wrapper. This closes the SLEEP connection cleanly.- FPM monitor false positives — orphaned-lock detection in
fpm-saturation-monitor.shpreviously killedpi2s3-lockconnections whenever found, including during active backups. Now checkspgrep -f pi-image-backup.shfirst; if the backup is running, the lock is legitimate and is left alone. probe_stop()double-zero syntax error —grep -c ... || echo 0produced"0\n0"underset -uwhen the log file didn't exist (grep exits 1 on no match, triggering the fallback alongside the empty output). Fixed with|| trueand${var:-0}expansion.db_kill_orphaned_locks()unbound variable —local _idswithout initialisation crashed underset -u. Fixed withlocal _ids="".
FPM_RESTART_COOLDOWNdefault is1200(20 min).
- Zero-downtime DB lock (
DB_CONTAINER) — replacesSTOP_DOCKERfor MariaDB/MySQL setups. IssuesFLUSH TABLES WITH READ LOCKbefore imaging andUNLOCK TABLESafter. All containers stay running throughout — only DB writes are blocked during the imaging window (~5–15 min). Same technique used by mariabackup/xtrabackup.DB_CONTAINER="auto"— scans running containers for any image containingmariadbormysql; falls back toSTOP_DOCKERif none foundDB_CONTAINER="pi_mariadb"— explicit container name for deterministic setupsDB_CONTAINER=""withDB_ROOT_PASSWORDset — native (non-Docker) MariaDB/MySQL on localhostDB_ROOT_PASSWORDauto-read from container environment if not explicitly set (MYSQL_ROOT_PASSWORD/MARIADB_ROOT_PASSWORD)
- Site availability probe — background curl loop that pings your site every
PROBE_INTERVALseconds (default: 60) while partclone runs. Each request is cache-busted (?pi2s3t=<timestamp>) and sent withCache-Control: no-cacheheaders to bypass CDN and WP page cache.PROBE_LATEST_POST=true— auto-discovers the latest WordPress post via REST API and probes that URL instead of the homepage, testing real dynamic content- Results logged and included in the ntfy success notification (e.g.
probe: 8/8 pass)
DB_LOCK→STOP_DOCKERfallback — ifdb_lock()fails (wrong password, no DB, etc.) the script automatically falls back to the standard Docker stop and continues the backup- Parallel partition imaging — boot firmware partition (SD card) runs concurrently with the last NVMe partition.
BACKUP_EXTRA_DEVICEpartitions run concurrently with the entire boot-device imaging. Both use separate physical buses so reads don't contend. Implemented viaimage_to_s3()helper that works identically inline or backgrounded. BACKUP_EXTRA_DEVICEimplemented — was documented inconfig.env.examplebut never coded. Now fully functional: enumerates partitions on the extra device, images them in background parallel with boot device, adds results to manifest underextra_device_partitions.image_to_s3()helper function — extracted from inline imaging code. Handles the fullpartclone | pigz | [gpg] | [pv] | aws s3 cppipeline and writessha256=…\ncompressed=…to a temp result file. Eliminates code duplication across boot partitions, boot firmware, and extra device.db_kill_orphaned_locks()— called unconditionally beforedb_lock()on every run. Queriesinformation_schema.PROCESSLISTfor survivingSELECT /* pi2s3-lock */ SLEEP(86400)connections left by a previous crashed backup and kills them. Prevents the new backup from hanging when FTWRL blocks on a stale lock.fpm-saturation-monitor.sh— host cron script (every minute) that detects PHP-FPM worker pool exhaustion and alerts via ntfy.sh. Three mechanisms: HTTP probe, MariaDB long-running queries (>15 s fromwordpressuser), orphanedpi2s3-lockconnections (killed immediately). Configurable:FPM_SATURATION_THRESHOLD,FPM_PROBE_URL,FPM_ALERT_COOLDOWN. OptionalFPM_CALLBACK_URL/FPM_CALLBACK_TOKENfor CloudScale Devtools plugin reporting.
STOP_DOCKERis now the fallback path only;DB_CONTAINER="auto"(the default) attempts FTWRL lock first and falls back to Docker stop only if no DB is found- Early FTWRL release —
db_unlock()now called immediately aftersync+drop_caches, before the partclone imaging loop. Site writes resume ~5 seconds into the backup window instead of after 15–30 minutes of imaging. db_unlock()kill-before-wait —kill "${_DB_LOCK_PID}"called beforewaitso unlock is always fast regardless of SQL KILL race outcome.config.env.example: DB lock section added beforeSTOP_DOCKERwith inline documentation; probe section added- README + website hero updated to make clear zero-downtime is the default — no config change needed for MariaDB/MySQL Docker setups
probe_stopis now always called (even when_USE_DB_LOCK=false) so the probe summary is always collected and logged.
- Client-side encryption (
BACKUP_ENCRYPTION_PASSPHRASE) — GPG AES-256 encrypts each partition image before upload. Passphrase stored inconfig.envonly; never written to S3. Restore script auto-detects encryption from manifest"encryption"field and decrypts inline; prompts interactively if passphrase not inconfig.env. - Post-upload auto-verify (
BACKUP_AUTO_VERIFY=true, on by default) — after every backup, re-lists S3 to confirm all uploaded files are non-zero. Result included in ntfy success notification. - Pre/post backup hooks (
PRE_BACKUP_CMD/POST_BACKUP_CMD) — shell commands run before and after partition imaging. For non-Docker setups (native MariaDB, nginx, php-fpm, etc.).on_exitcrash trap callsPOST_BACKUP_CMDon failure so services always restart. Aborts backup cleanly ifPRE_BACKUP_CMDexits non-zero. --costflag — lists per-date S3 sizes and calculates estimated monthly cost by storage class. Reads directly from S3; no re-download needed.--helpflag — prints full usage to stdout for bothpi-image-backup.shandpi-image-restore.sh.- GitHub Actions CI —
bash -nsyntax check on all 6 scripts on every push and pull request tomain. - Website: Security & Reliability section — 7 cards covering client-side encryption, bandwidth throttle, auto-verify, preflight health checks, stale backup alert, crash-safe Docker restart, and pre/post hooks.
- Website: version badge in footer linking to GitHub releases.
- README: CI badge, multi-Pi section,
--cost/--helpin flags table,BACKUP_ENCRYPTION_PASSPHRASEconfig, encryption passphrase security warning, list/verify sections with example output, pre/post backup hooks section.
- Branding:
ntfy_send "Pi MI backup complete"→ntfy_send "pi2s3 backup complete"inpi-image-backup.sh. pi-image-restore.shcomments and log statements updated from "Pi MI" → "pi2s3".- README: "Pi MI" → "pi2s3" in comparison table and prose.
- SHA-256 checksums in-flight — each partition's compressed stream is forked via
tee >(sha256sum ...)and hashed simultaneously with upload; no re-download required. Checksums stored per partition in the manifest JSON.--verifyprints stored checksums for spot-checking. - Partial / file-level restore (
--extract) — streams a partition from S3, restores into a sparse temp file via loop device, mounts read-only, and copies the requested path to./pi2s3-extract-<date>/. No target device needed. Options:--extract <path>,--partition <name>,--date <YYYY-MM-DD>. - Cross-device restore (
--resize) — after restore, runsgrowpart+resize2fs(ext4) to expand the last partition to fill a larger device. Advisory message for xfs/btrfs. - Per-host S3 namespacing — backups stored under
pi-image-backup/<hostname>/<date>/.pi-image-restore.shauto-discovers host prefixes; prompts if multiple exist.--hostflag for explicit selection. - Stale backup alert —
--stale-checkmode ntfys if the latest backup is older thanSTALE_BACKUP_HOURS(default: 25h). Installed as a daily cron byinstall.sh(STALE_CHECK_ENABLED=true). Catches silent cron failures. - Preflight health checks —
preflight_health()runs before Docker stop: checks for unhealthy/exited containers, free disk space (PREFLIGHT_MIN_FREE_MB), and recent I/O errors viadmesg.PREFLIGHT_ABORT_ON_WARN=trueto abort on warnings (default: proceed). - Bandwidth throttle —
AWS_TRANSFER_RATE_LIMITinconfig.envcaps S3 upload speed viapv -q -L <rate>(e.g.2m= 2 MB/s). Gracefully skips ifpvnot installed or var unset. - Per-host retention —
MAX_IMAGES_<hostname>=Ninconfig.envoverridesMAX_IMAGESfor a specific host (hyphens → underscores). Enables different retention windows per Pi in multi-Pi setups. - Failure ntfy alerts include last 10 log lines — diagnose failures from the push notification without SSH.
- Post-backup container safety check — separate cron job ~30 min after backup verifies Docker came back up. Guards against mid-imaging crashes leaving containers stopped.
- Website — pi2s3 logo in hero; Prerequisites section with IAM policy; Troubleshooting section; partial-restore and --resize usage; andrewbaker.ninja nav link.
test-recovery.sh --pre-flashchecks per-partition SHA-256 fields in manifest (replaces old singledevice_sha256field). Warns gracefully for backups that predate checksum support.
- Dropped planned TODO(7) incremental backup — restore complexity outweighs cost savings at 3–5 GB/day compressed. Full images restore in one command with no history dependency.
- Rename: pi-mi → pi2s3 throughout codebase, cron, log paths, and install script.
- pi2s3.com website — dark-mode SPA covering architecture, quickstart, restore steps, watchdog, and coverage table.
- RECOVERY.md — full disaster-recovery runbook: hardware checklist, bootstrap SD card flashing from macOS, restore procedure, post-boot verification, NVMe-only recommendation.
-Fflag on partclone — allows cloning mounted partitions (all NVMe partitions remain mounted during backup). Eliminates the need to unmount partitions separately.- Docker stops for full imaging duration — Docker is stopped before the first partition starts and restarted after the last partition finishes, ensuring full write consistency across all partitions in a multi-partition backup.
- Recovery safety net — if the backup fails, the on-exit handler attempts to restart Docker so containers don't stay down.
- Verified uploads — after each S3
cp, the script confirms the S3 object exists and is non-zero before continuing. - Always-notify on exit —
on_exittrap fires for any non-zero exit, ensuring a failure notification is always sent even if the script crashes mid-run. install.shimprovements — creates log file before cron install;--statuswarns if log file is missing.
Initial release.
- Block-level nightly backup of Raspberry Pi to S3 using partclone + pigz
- Streaming upload: no local temp file (direct Pi → S3)
- Partition table saved as sfdisk dump and restored first
- Boot firmware partition on separate SD card backed up separately
- Manifest JSON with hostname, Pi model, OS, partition layout, sizes, duration
pi-image-restore.sh— interactive and non-interactive full restoretest-recovery.sh— pre-flash validation and post-boot verificationpi2s3-watchdog.sh— three-phase Cloudflare tunnel + Docker self-healing monitor- S3 lifecycle policy + STANDARD_IA storage class
- ntfy.sh push notifications (success, failure, watchdog events)
install.sh— full setup, watchdog install, upgrade, status, uninstall--dry-runmode for safe testing- Legacy dd format support (reads old
.img.gzbackups)