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

set -euo pipefail


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

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" "">
<plist version="1.0">

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
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.