Here’s the copy-icloud-photos script I use to backup my photos stored on iCloud to my Synology NAS:

#!/bin/bash
set -euo pipefail

args=(
  --delete
  --human-readable
  --no-perms
  --partial
  --progress
  --times
  -v
)

src="/Users/jyc/Pictures/Photos Library.photoslibrary/originals/"
cd "$src"
find ./ -cmin +1440 -print0 |
  rsync --files-from=- --from0 \
    "${args[@]}" \
    "./" \
    nas.home:/var/services/homes/jyc/Photos/iCloudViaMac

I added find recently because it’s annoying to accidentally backup temporary photos, like screenshots, that only live in my iPhone’s Camera Roll for a minute or so before I delete them.

I have launchd run that script daily using a configuration plist at ~/Library/LaunchAgents/jyc.copy-icloud-photos.service:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Disabled</key>
  <false/>
  <key>Label</key>
  <string>copy-icloud-photos</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/fdautil</string>
    <string>exec</string>
    <string>/Users/jyc/bin/copy-icloud-photos</string>
  </array>
  <key>StandardErrorPath</key>
  <string>/tmp/copy-icloud-photos.err</string>
  <key>StandardOutPath</key>
  <string>/tmp/copy-icloud-photos.out</string>
  <key>StartInterval</key>
  <integer>86400</integer>
</dict>
</plist>

I set it up via LaunchControl, which is a third-party shareware GUI for launchd that also provides the fdautil wrapper script that makes it possible for the copy-icloud-photos script to have full disk access. I think it’s possible to get this to work without LaunchControl but I haven’t tried.

Unfortunately a big caveat is that this will back up recently deleted photos until they are truly deleted by iCloud. Here’s some lists filenames of non-deleted non-hidden photos under ~/Pictures/Photos Library.photoslibrary/originals/ when run on the database at ../Photos.sqlite:

select substr(ZFILENAME, 1, 1) || '/' || ZFILENAME
from ZASSET
where ZTRASHEDSTATE = 1 and ZHIDDEN = 0;

… but even when I grant bash, copy-icloud-photos, and sqlite3 Full Disk Access in System Settings > Privacy & Security, I can’t get it to work. I thought I might just need to grant my script Photos access as well, but that doesn’t work. Maybe Apple really is trying to block all programmatic access except through PhotoKit.