Was this useful to you? Please consider a donation https://ko-fi.com/xanderphotoau
Backup iCloud Photos to NAS
Using Docker image boredazfck/icloudpd, you can mirror iCloud Photo Library to Download and Mirror your photos to your self-hosted NAS system, creating a local backup.
icloudpd also supports auto_delete, and with this function turned on, deleting a photo on your phone will also hard-delete it from the NAS. This creates a problem as there is no redundancy or fall-back to recover a deleted item on the NAS.
This enhancement adds a self-purging recycle bin that runs alongside icloudpd. The ETL uses hardlinks, enabling a 30-day recovery that mirrors how iCloud Photos Recently Deleted operates.
The problem
Photo-cleaning apps like Cully, This Time Last Year, and most other library tidy-up tools only let you delete photos from iCloud. None of them let you move the ones worth keeping into an album instead. That left me with no safe way to clear out my iCloud library without risking photos I might still want.
This recycle bin is my workaround: it keeps a backup of everything those apps delete, so I can review it on the NAS and manually copy the keepers into my Archive folder. From there, a separate Docker service, like Immich, turns that archive into a personal cloud: a searchable photo library of everything I’ve pulled out of iCloud.
The workflow (ETL)
The recycle bin isn’t a standalone gadget — it’s one stage in a small pipeline built from two boredazfcuk/icloudpd containers on a QNAP NAS (Portainer via Container Station), plus the sidecar. The pipeline exists to make culling safe: clean up iCloud from your phone, but keep a copy on the NAS long enough to triage into a permanent archive.
Most iOS culling apps — Cully, This Time Last Year, and the like — only let you delete photos; they can’t move them to an album. So there’s no clean “archive this, don’t lose it” path. This pipeline works around that: the delete becomes the signal, and the recycle bin becomes the staging area you copy keepers out of.
- Cull · iOS
Delete in iCloud
Use Cully or This Time Last Year to clear out the iCloud library. They can only delete — which is exactly the gap the rest of this fills.
- Extract · daily
Library mirror
icloudpd-librarydownloads everything in iCloud to~/iCloud/Libraryon the NAS, once a day. - Replicate · delete
Sync the deletions
Once the download completes, Library removes anything you deleted in iCloud, keeping the folder an exact mirror of your phone.
- Capture · 30 days
Recycle bin
The sidecar catches each of those deletions into
.recyclebin/deleted/<date>and retains it for 30 days — the same window as iCloud’s own Recently Deleted. - Load · manual
Copy keepers to Archive
Browse the bin and copy anything worth keeping into
~/iCloud/Archive— a deliberate, manual triage rather than an automatic dump.
A second container, icloudpd-albums, mirrors the same download process into ~/iCloud/Archive — but it only pulls from the iCloud Archive album, and it never deletes. Anything in that album (or copied into the folder by hand from the recycle bin) stays put even after it’s gone from iCloud, giving you a permanent, offline archive that outlives the Library mirror.
How the recycle bin works
A tiny companion container watches your auto-delete folder and runs three steps each pass. The whole thing rests on one Unix fact: a hardlink is a second name for the same data, so a file isn’t truly gone until its last name is removed.
Link every file
Walk the live folder and create a hardlink to each file inside a hidden .shadow tree. Same bytes, second name — costs essentially no disk.
Catch the deletes
For each shadow link whose live file is gone, the data survives via that link. Move it into deleted/<date>/ — your recoverable copy.
Age it out
Delete anything in the bin older than the retention window (default 30 days). Only then are those bytes actually freed.
It tells a real delete from a rename by checking the link count: a genuinely deleted file has only its shadow link left, while a moved file still has a live link elsewhere and is quietly skipped. The watched photos are never touched — the sidecar only ever reads them and acts on links it made itself.
Setup
A script on disk plus the stack. The full iCloud stack is shown below (anonymised); the recycle-bin is the third container. Paths are QNAP examples — adjust for your system. The one hard rule is the mount, called out below.
- Create a folder for the script and drop the Python file in it, e.g. /share/CACHEDEV1_DATA/Container/icloudpd-recycle/icloudpd_recyclebin.py
- Add the recycle-bin service (the third container in the stack below) to the stack that runs icloudpd, then redeploy — or deploy the whole stack if you’re starting fresh.
- Point
WATCH_PATHSat whichever folder yourauto_delete=trueinstance writes to (not your archive folders, which never delete). - Deploy with
DRY_RUN: "1"first to preview in the logs, then switch to"0".
The live folder and the recycle bin must sit on a single shared bind mount. Hardlinks fail across separate Docker mounts (EXDEV) even on the same volume — so mount the common parent (here /share/CACHEDEV1_DATA/iCloud) as one volume and keep both the live folder and .recyclebin inside it.
The full stack
The complete setup is three containers: icloudpd-library mirrors all of iCloud and replicates deletions, icloudpd-albums archives chosen albums and never deletes, and icloudpd-recyclebin is the sidecar that catches Library’s deletions. Swap in your own apple_id and Pushover keys, and adjust the paths and timezone.
version: "3.8"
services:
# 1. LIBRARY — mirrors ALL of iCloud and replicates deletions (auto_delete=true).
# This is the folder the recycle-bin watches.
icloudpd-library:
image: boredazfcuk/icloudpd:latest
container_name: icloudpd-library
restart: always
environment:
- TZ=Australia/Melbourne
- apple_id=your-apple-id@example.com
- authentication_type=MFA
- auto_delete=true
- config_file=/config/icloudpd.conf
- delete_empty_directories=true
- delete_notifications=true
- download_notifications=true
- download_path=/iCloud/Library
- folder_structure={:%Y/%Y-%m/%Y-%m-%d}
- notification_type=Pushover
- pushover_token=YOUR_PUSHOVER_APP_TOKEN
- pushover_user=YOUR_PUSHOVER_USER_KEY
- set_exif_datetime=false
- skip_check=true
- startup_notification=true
- synchronisation_interval=86400
volumes:
- /share/CACHEDEV1_DATA/Container/icloudpd/config-library:/config
- /share/CACHEDEV1_DATA/iCloud/Library:/iCloud/Library
labels:
- helper.command.initialise=sync-icloud.sh --Initialise
- helper.command.reauth=reauth.sh
# 2. ALBUMS — downloads only chosen iCloud albums and NEVER deletes
# (auto_delete=false): your permanent, offline archive.
icloudpd-albums:
image: boredazfcuk/icloudpd:latest
container_name: icloudpd-albums
restart: always
environment:
- TZ=Australia/Melbourne
- apple_id=your-apple-id@example.com
- authentication_type=MFA
- auto_delete=false
- config_file=/config/icloudpd.conf
- delete_empty_directories=false
- delete_notifications=false
- download_notifications=true
- download_path=/iCloud
- folder_structure={:%Y/%Y-%m/%Y-%m-%d}
- notification_type=Pushover
- pushover_token=YOUR_PUSHOVER_APP_TOKEN
- pushover_user=YOUR_PUSHOVER_USER_KEY
- set_exif_datetime=false
- skip_check=true
- startup_notification=true
- synchronisation_interval=87400
- albums_with_dates=true
- photo_album=Archive,Favourites,Content
volumes:
- /share/CACHEDEV1_DATA/Container/icloudpd/config-albums:/config
- /share/CACHEDEV1_DATA/iCloud/Archive:/iCloud/Archive
- /share/CACHEDEV1_DATA/iCloud/Favourites:/iCloud/Favourites
- /share/CACHEDEV1_DATA/iCloud:/iCloud
labels:
- helper.command.initialise=sync-icloud.sh --Initialise
- helper.command.reauth=reauth.sh
# 3. RECYCLE-BIN — the sidecar. Watches Library only and preserves its
# deletions for 30 days, using the script published below.
icloudpd-recyclebin:
image: python:3.13-slim
container_name: icloudpd-recyclebin
restart: always
environment:
TZ: Australia/Melbourne
WATCH_PATHS: /data/icloud/Library # the auto_delete folder only
RECYCLE_ROOT: /data/icloud/.recyclebin
RETENTION_DAYS: "30"
RUN_AT: "03:30" # after the Library sync finishes
RUN_ON_START: "1"
DRY_RUN: "0" # "1" to preview without changes
volumes:
# ONE shared bind mount holds BOTH the live folder and the recycle bin
- /share/CACHEDEV1_DATA/iCloud:/data/icloud
- /share/CACHEDEV1_DATA/Container/icloudpd-recycle:/app:ro
command: ["python", "/app/icloudpd_recyclebin.py"]
The script
Pure standard library — no pip install, no network. Save it as icloudpd_recyclebin.py in the folder from step 1.
#!/usr/bin/env python3
"""
icloudpd_recyclebin.py
----------------------
A safety net for iCloudpd's --auto-delete on a NAS.
iCloudpd hard-deletes (unlink) local files when the matching photo is removed
in iCloud. This script gives those deletions a recoverable "Deleted" folder,
WITHOUT doubling disk usage, by using hardlinks.
How it works each pass:
1. SEED - For every live file, ensure a hardlink to it exists in a
hidden .shadow tree. (Hardlink = same inode, ~0 extra bytes.)
2. RECONCILE - Walk the .shadow tree. If a shadow entry's live counterpart
is gone AND the inode now has only this one link, iCloudpd
deleted it -> move the hardlink into deleted/<date>/...
If the inode still has other links, the file was just
moved/renamed in the live tree -> drop the stale shadow link.
3. PURGE - Delete recycle entries older than RETENTION_DAYS.
Pure standard library. No pip install needed.
REQUIREMENT: every WATCH_PATH and the RECYCLE_ROOT must sit on the SAME
filesystem / same Docker bind mount, or hardlinks (os.link) will fail with
EXDEV. Mount the common parent (e.g. /share/CACHEDEV1_DATA/iCloud) as ONE
volume and point both inside it.
Environment variables:
WATCH_PATHS Comma-separated dirs iCloudpd manages with --auto-delete.
e.g. "/data/icloud/Library,/data/icloud/Content"
Default: /data/icloud/Library
RECYCLE_ROOT Where .shadow and deleted/ live (hidden, NOT scanned).
Default: /data/icloud/.recyclebin
RETENTION_DAYS Days to keep deleted items. Default: 30
RUN_AT Daily run time "HH:MM" (24h, container local time).
Default: 03:30. Ignored if RUN_INTERVAL_SECONDS set.
RUN_INTERVAL_SECONDS Run every N seconds instead of a fixed time. Optional.
RUN_ON_START "1" to run one pass immediately on startup. Default: 1
ONE_SHOT "1" to run a single pass and exit (no loop). Default: 0
DRY_RUN "1" logs what it WOULD do, changes nothing. Default: 0
EXCLUDE_DIRS Comma-separated dir names to skip anywhere in the tree.
Default: @eaDir,@Recycle,.@__thumb,.recyclebin,#recycle
"""
import os
import sys
import shutil
import time
from datetime import datetime, timedelta
# ----------------------------- config -----------------------------
def env(key, default):
v = os.environ.get(key)
return v if v not in (None, "") else default
WATCH_PATHS = [p.strip() for p in env("WATCH_PATHS", "/data/icloud/Library").split(",") if p.strip()]
RECYCLE_ROOT = env("RECYCLE_ROOT", "/data/icloud/.recyclebin")
RETENTION_DAYS = int(env("RETENTION_DAYS", "30"))
RUN_AT = env("RUN_AT", "03:30")
RUN_INTERVAL_SECONDS = env("RUN_INTERVAL_SECONDS", "")
RUN_ON_START = env("RUN_ON_START", "1") == "1"
ONE_SHOT = env("ONE_SHOT", "0") == "1"
DRY_RUN = env("DRY_RUN", "0") == "1"
EXCLUDE_DIRS = set(
d.strip() for d in env("EXCLUDE_DIRS", "@eaDir,@Recycle,.@__thumb,.recyclebin,#recycle").split(",") if d.strip()
)
SHADOW_ROOT = os.path.join(RECYCLE_ROOT, ".shadow")
DELETED_ROOT = os.path.join(RECYCLE_ROOT, "deleted")
# label -> absolute live root. Label = folder basename, used as a namespace
# inside the shadow/deleted trees so paths map back unambiguously.
def build_watches():
watches = {}
for p in WATCH_PATHS:
p = os.path.abspath(p)
label = os.path.basename(p.rstrip("/")) or "root"
if label in watches:
log(f"WARNING: duplicate watch label '{label}' for {p}; skipping")
continue
watches[label] = p
return watches
def log(msg):
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}", flush=True)
def is_excluded(path):
parts = path.split(os.sep)
return any(part in EXCLUDE_DIRS for part in parts)
def iter_files(root):
for dirpath, dirnames, filenames in os.walk(root):
# prune excluded dirs in-place so we don't descend into them
dirnames[:] = [d for d in dirnames if d not in EXCLUDE_DIRS]
for name in filenames:
full = os.path.join(dirpath, name)
if os.path.islink(full):
continue
yield full
def same_inode(a, b):
try:
sa, sb = os.lstat(a), os.lstat(b)
except FileNotFoundError:
return False
return (sa.st_dev, sa.st_ino) == (sb.st_dev, sb.st_ino)
# ----------------------------- phases -----------------------------
def seed(watches):
"""Ensure every live file has a current hardlink in .shadow."""
created = refreshed = 0
for label, root in watches.items():
if not os.path.isdir(root):
log(f"SEED: watch path missing, skipping: {root}")
continue
for live in iter_files(root):
rel = os.path.relpath(live, root)
shadow = os.path.join(SHADOW_ROOT, label, rel)
if os.path.exists(shadow):
if same_inode(live, shadow):
continue
# live file was replaced (re-downloaded): refresh the link
if not DRY_RUN:
os.unlink(shadow)
os.makedirs(os.path.dirname(shadow), exist_ok=True)
os.link(live, shadow)
refreshed += 1
else:
if not DRY_RUN:
os.makedirs(os.path.dirname(shadow), exist_ok=True)
try:
os.link(live, shadow)
except OSError as e:
if e.errno == 18: # EXDEV
log("FATAL: cross-device link. WATCH_PATHS and "
"RECYCLE_ROOT must be on the same bind mount/volume.")
sys.exit(2)
raise
created += 1
log(f"SEED: {created} new, {refreshed} refreshed{' (dry-run)' if DRY_RUN else ''}")
def reconcile(watches):
"""Move shadow entries whose live file is gone into deleted/<date>/."""
today = datetime.now().strftime("%Y-%m-%d")
deleted = moved_alias = 0
if not os.path.isdir(SHADOW_ROOT):
return
for label in os.listdir(SHADOW_ROOT):
label_root = os.path.join(SHADOW_ROOT, label)
if not os.path.isdir(label_root):
continue
live_root = watches.get(label)
for shadow in iter_files(label_root):
rel = os.path.relpath(shadow, label_root)
live = os.path.join(live_root, rel) if live_root else None
if live and os.path.exists(live):
continue # still present, nothing to do
# Live counterpart is gone.
st = os.lstat(shadow)
if st.st_nlink >= 2:
# The data still exists via another hardlink (file was
# moved/renamed in the live tree; SEED already linked the
# new path). Drop this stale shadow link only.
if not DRY_RUN:
os.unlink(shadow)
moved_alias += 1
else:
# Sole remaining link -> genuine deletion. Preserve it.
dest = os.path.join(DELETED_ROOT, today, label, rel)
if not DRY_RUN:
os.makedirs(os.path.dirname(dest), exist_ok=True)
# avoid clobbering if same path deleted twice in one day
final = dest
n = 1
while os.path.exists(final):
base, ext = os.path.splitext(dest)
final = f"{base}.dup{n}{ext}"
n += 1
os.replace(shadow, final)
deleted += 1
log(f" recycled: {label}/{rel}")
log(f"RECONCILE: {deleted} recycled, {moved_alias} stale aliases pruned"
f"{' (dry-run)' if DRY_RUN else ''}")
def purge():
"""Delete recycle date-folders older than RETENTION_DAYS; tidy empties."""
removed = 0
if os.path.isdir(DELETED_ROOT):
cutoff = datetime.now().date() - timedelta(days=RETENTION_DAYS)
for name in os.listdir(DELETED_ROOT):
path = os.path.join(DELETED_ROOT, name)
if not os.path.isdir(path):
continue
try:
d = datetime.strptime(name, "%Y-%m-%d").date()
except ValueError:
continue
if d < cutoff:
if not DRY_RUN:
shutil.rmtree(path, ignore_errors=True)
removed += 1
log(f" purged old recycle folder: {name}")
# remove now-empty dirs in the shadow tree
if os.path.isdir(SHADOW_ROOT) and not DRY_RUN:
for dirpath, dirnames, filenames in os.walk(SHADOW_ROOT, topdown=False):
if dirpath == SHADOW_ROOT:
continue
if not os.listdir(dirpath):
try:
os.rmdir(dirpath)
except OSError:
pass
log(f"PURGE: {removed} old recycle folder(s) removed"
f"{' (dry-run)' if DRY_RUN else ''}")
def run_pass():
watches = build_watches()
log(f"PASS start | watching: {', '.join(f'{k}={v}' for k, v in watches.items())}")
log(f"recycle root: {RECYCLE_ROOT} | retention: {RETENTION_DAYS}d | dry_run: {DRY_RUN}")
if not DRY_RUN:
os.makedirs(SHADOW_ROOT, exist_ok=True)
os.makedirs(DELETED_ROOT, exist_ok=True)
seed(watches) # protect new/changed files first
reconcile(watches) # then detect what iCloudpd removed
purge()
log("PASS done")
# ----------------------------- scheduling -----------------------------
def seconds_until(hhmm):
now = datetime.now()
try:
hh, mm = (int(x) for x in hhmm.split(":"))
except Exception:
log(f"Bad RUN_AT '{hhmm}', defaulting to 03:30")
hh, mm = 3, 30
nxt = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
if nxt <= now:
nxt += timedelta(days=1)
return (nxt - now).total_seconds(), nxt
def main():
log("icloudpd recycle-bin starting")
if RUN_ON_START or ONE_SHOT:
run_pass()
if ONE_SHOT:
return
while True:
if RUN_INTERVAL_SECONDS:
wait = int(RUN_INTERVAL_SECONDS)
log(f"sleeping {wait}s until next pass")
time.sleep(wait)
else:
wait, nxt = seconds_until(RUN_AT)
log(f"next pass at {nxt.strftime('%Y-%m-%d %H:%M')} ({int(wait)}s)")
time.sleep(wait)
run_pass()
if __name__ == "__main__":
main()
Operating it
Scheduled
It runs a full pass daily at RUN_AT (and once on container start). Set that time to land after your icloudpd sync finishes, so it always reconciles after the delete pass.
On demand
If you trigger syncs manually, run a pass on demand two ways:
- Restart the container — with
RUN_ON_START=1it does a pass immediately, then resumes its schedule. - One-shot, without disturbing the loop:
docker exec -e ONE_SHOT=1 icloudpd-recyclebin python /app/icloudpd_recyclebin.py
Order matters: run the recycle pass after the icloudpd sync has finished deleting, or there’s nothing yet to catch.
Where the recycled files live
Recovered files are organised by the date they were caught:
/share/CACHEDEV1_DATA/iCloud/.recyclebin/deleted/<date>/Library/…
The .recyclebin name is hidden, so enable Show hidden files in File Station (or your SMB client) to browse it. Rename RECYCLE_ROOT to something without the leading dot if you’d rather it show as a normal folder.
Copy it out of the bin to somewhere safe — but not back into the watched folder. That folder is an iCloud mirror, so if the photo is no longer in iCloud, the next sync will just delete it again. Restore it elsewhere, or re-add it to iCloud first.
Limitations & notes
- Seed before delete. A file is only recoverable if it was seeded on an earlier pass. A photo added and deleted within the same cycle, before any pass runs, won’t be caught. The daily pass covers normal use; only matters in tight back-to-back testing.
- Deleted bytes do take space. While a file is alive, its shadow link is free. Once the live copy is gone, the bin holds the only copy, so it occupies real space until it’s purged — exactly as a recycle bin should.
- Watch only auto-delete folders. Pointing it at folders that never delete just builds shadow links for no reason.
- It never deletes your photos. The sidecar only reads the live folder and moves links it created. The actual deletes are icloudpd’s; this just catches what falls.
Customise it
Different NAS, different paths, different culling habits — the parts are generic, but the paths and timing are yours. Paste the prompt below into an AI assistant (Claude, ChatGPT, or similar) together with the script above, fill in the angle-bracket placeholders, and it’ll tailor the compose service and deploy steps to your environment — and check your paths against the one hard rule before you run anything.
I want to add a "recycle bin" safety net to my icloudpd photo-backup setup. When
icloudpd hard-deletes a file locally (because I deleted the photo in iCloud), I
want a recoverable copy kept for a retention window instead of it being lost.
How the solution works: a small sidecar container (Python, standard library only,
no pip installs) watches the folder that auto-deletes. Each pass it:
1. SEED - makes a hardlink to every live file inside a hidden .shadow tree
(same bytes, second name -> ~0 extra disk).
2. RECONCILE - any file now missing from the live folder but still present via
its shadow link was deleted -> it moves that link into
deleted/<date>/, i.e. the recycle bin.
3. PURGE - removes anything in the bin older than the retention window.
Critical constraints to respect:
- The live folder and the recycle-bin folder MUST sit on a SINGLE shared bind
mount. Hardlinks fail across separate Docker mounts (EXDEV) even on the same
volume, so mount the common parent as one volume with both inside it.
- Only watch the folder that actually auto-deletes. Do NOT watch archive /
never-delete folders.
- A file is only recoverable if it was seeded on an earlier pass, so the
recycle pass must run AFTER the icloudpd sync, and the file must have existed
through at least one prior pass.
(The reference script is the icloudpd_recyclebin.py published with this guide —
assume I can provide it; build the compose and steps around it.)
My setup (fill these in):
- NAS / OS: <e.g. QNAP QTS, Synology DSM, generic Linux>
- Container manager: <e.g. Portainer via Container Station, compose CLI>
- icloudpd image: <e.g. boredazfcuk/icloudpd:latest>
- Auto-delete host path: <e.g. /share/CACHEDEV1_DATA/iCloud/Library>
- Common parent to mount: <e.g. /share/CACHEDEV1_DATA/iCloud>
- Where I'll store the script: <e.g. /share/CACHEDEV1_DATA/Container/icloudpd-recycle>
- Timezone: <e.g. Australia/Melbourne>
- Retention (days): <e.g. 30>
- When my icloudpd sync finishes (to schedule the pass): <e.g. ~03:00>
Please:
1. Give me the sidecar's docker-compose service tailored to my paths and timezone.
2. Confirm my paths satisfy the single-mount hardlink rule, or tell me how to fix them.
3. Give me the deploy steps for my container manager.
4. Tell me how to trigger a pass on demand, and where to find and restore recycled files.
5. Flag anything specific to my NAS (hidden-folder browsing, recycle-bin behaviour, permissions).
Ask me for anything missing before giving the final config.
