Keep a folder in sync with a public iCloud Shared Album — photos, videos, and all — using a tiny Docker image and a minimal Python + httpx stack.
Point it at a Shared Album URL, mount a folder, and let it run. Anything anyone adds to the album shows up in the folder on the next sync. Delete something from the album and it disappears locally too. Great for digital picture frames, family photo backups, or any workflow that ends with "…and now I want those photos on my server."
Building a digital picture frame? Pair with icloud-album-kiosk — the display half of the same two-container appliance. This tool writes to the folder; the kiosk renders it as a full-screen crossfading slideshow.
docker run --rm \
-e SHARED_ALBUM_URL='https://www.icloud.com/sharedalbum/#B2AJ...' \
-v "$PWD/photos:/photos" \
ghcr.io/bitwise-forge/icloud-shared-album-sync:latestOne shot — pulls the current album contents into ./photos/ and exits. Set SYNC_INTERVAL_HOURS if you want it to loop on its own.
Compose is the recommended shape if you have more than one album. One service per album, each with its own URL, its own folder, and its own cadence:
services:
frame-parents:
image: ghcr.io/bitwise-forge/icloud-shared-album-sync:latest
environment:
SHARED_ALBUM_URL: 'https://www.icloud.com/sharedalbum/#B2AJ...'
SYNC_INTERVAL_HOURS: '12'
volumes:
- ./photos/frame-parents:/photos
restart: unless-stopped
frame-in-laws:
image: ghcr.io/bitwise-forge/icloud-shared-album-sync:latest
environment:
SHARED_ALBUM_URL: 'https://www.icloud.com/sharedalbum/#B2BK...'
SYNC_INTERVAL_HOURS: '24'
volumes:
- ./photos/frame-in-laws:/photos
restart: unless-stopped| Variable | Default | Description |
|---|---|---|
SHARED_ALBUM_URL |
(required) | The full public Shared Album URL, in either shape Apple hands out: https://www.icloud.com/sharedalbum/#TOKEN (classic) or https://share.icloud.com/photos/TOKEN (short link). Get it from Photos.app → Share → Public Website. |
OUTPUT_DIR |
/photos |
Where inside the container to write assets. Mount a host folder here. |
SYNC_INTERVAL_HOURS |
0 |
If > 0, run continuously and sleep this many hours between syncs. If 0 (the default), sync once and exit. |
STORAGE_BUFFER_PERCENT |
10 |
Reserves this percentage of the output volume's total capacity as untouchable headroom for the OS, logs, and anything else sharing the disk. Accepts a float (e.g. 7.25), rounded to two decimal places. Range [0, 100). |
AUTOPRUNE_ON_LOW_STORAGE |
false |
If false and the album would exceed the available budget, the sync logs an error and skips the run — nothing on disk is touched. If true, the sync keeps the newest slice of the album that fits under the budget and prunes older photos locally to make room. Newest is defined by upload time (batchDateCreated), then capture time (dateCreated), then photoGuid as a deterministic tiebreaker. |
APPLE_API_TIMEOUT_SECONDS |
180 |
Read timeout for Apple's manifest calls (webstream, webasseturls). These scale with the album's asset count, not its byte volume — a 210-asset album takes ~32s to answer webstream, a 103-asset album ~17s — so this needs headroom as the album grows. If a call still times out, the ceiling is doubled and the call is retried exactly once; see Timeouts and retries. |
DOWNLOAD_TIMEOUT_SECONDS |
120 |
Read timeout for a single asset download. Large videos genuinely take time to stream, so this is separate from the manifest timeout. |
RETRY_SLEEP_MINUTES |
5 |
In daemon mode (SYNC_INTERVAL_HOURS > 0), how long to wait after a failed run before trying again, instead of sleeping out the full interval. Doubles on each consecutive failure and never exceeds SYNC_INTERVAL_HOURS. Resets on the first success. |
LOG_LEVEL |
INFO |
Python logging level: DEBUG, INFO, WARNING, ERROR. |
Apple's Shared Streams API is a short conversation:
- Resolve the correct shard host for this album.
- Fetch the album manifest — one entry per photo/video, with contributor, date, caption, and per-derivative CDN references.
- Fetch signed CDN URLs (~3 hour expiry) for the assets we want.
- Download the best available derivative per asset. Photos use the largest numeric derivative (typically
2048, the long-edge in pixels). Videos use720pwhen present,360potherwise.
Files land under the filename Apple assigns, with a short hash of the asset's unique ID appended before the extension: IMG_5744.JPG → IMG_5744__a1b2c3d4.JPG. That hash is deterministic per asset, which does two things: it prevents collisions when two contributors happen to upload files with the same name, and it marks the file as "managed by this tool" so pruning can safely clean up without touching anything else in the folder.
EXIF, GPS, and iPhone-model metadata come through untouched inside Apple's shared-album compression. Re-runs are idempotent — assets whose local size matches the manifest are skipped, so a scheduled sync stays fast in steady state.
Apple's manifest endpoints get slower as an album grows, and the growth is in asset count, not bytes. Measured against real albums: webstream answers in ~17s for 103 assets and ~32s for 210, while the assets themselves total over 200 MB in both cases. An album that syncs fine today can cross a fixed timeout months later, purely by accumulating photos.
That failure is quiet by nature — the sync aborts, the local folder keeps whatever it already had, and nothing downstream looks broken. Three mechanisms address it:
A generous, configurable ceiling. APPLE_API_TIMEOUT_SECONDS defaults to 180, which covers roughly 1200 assets by the observed linear trend.
One-shot escalation. If a manifest call times out anyway, the ceiling is doubled and the call retried exactly once. A second timeout gives up rather than doubling again. The escalation is sticky per endpoint for the life of the process: an album too slow for the base ceiling will be too slow next cycle too, so subsequent calls start at the raised ceiling instead of re-paying the failed attempt every time. It clears itself as soon as that endpoint answers inside the base ceiling again. Escalation fires only on timeouts — not on HTTP errors, where a bigger budget would change nothing.
Early warning. Any manifest call consuming more than half its ceiling logs a warning while it's still succeeding:
WARNING slow Apple response: 32.8s of a 40.0s ceiling (82%) for webstream —
album growth pushes this toward a timeout; consider raising APPLE_API_TIMEOUT_SECONDS
Separately, individual asset downloads no longer abort the run. A failed download is logged, its partial file removed, and the cycle continues to the remaining assets and the prune step; the asset retries next cycle. If every attempted download fails, that's treated as systemic (link down, DNS, expired signed URLs) and raises, which triggers the RETRY_SLEEP_MINUTES fast-retry path instead of sleeping out a full interval.
The endpoints this tool uses (p*-sharedstreams.icloud.com) are the ones behind Apple's public web viewer at www.icloud.com/sharedalbum/. They are undocumented, unofficial, and can change or disappear on any iOS/macOS release. If Apple changes the shape of the response, this tool will break until it's updated to match.
If you rely on this in production, pin a specific version tag rather than tracking :latest.
Python 3.10 or newer, no runtime packages to install:
export SHARED_ALBUM_URL='https://www.icloud.com/sharedalbum/#B2AJ...'
export OUTPUT_DIR="$PWD/photos"
PYTHONPATH=src python3 -m icloud_syncOr via the project's uv-managed environment:
PYTHONPATH=src uv run python -m icloud_syncIf you'd rather build the image locally than pull from GHCR — for a private mirror, an air-gapped environment, or just to hack on the code — the Dockerfile is at the repo root and needs no build args:
git clone https://github.com/Bitwise-Forge/icloud-shared-album-sync
cd icloud-shared-album-sync
docker build -t icloud-shared-album-sync:local .Then substitute icloud-shared-album-sync:local wherever the Quickstart and Compose examples show ghcr.io/bitwise-forge/icloud-shared-album-sync:latest.
The resulting image is ~53 MB, based on python:3.14-alpine, and runs as a non-root app user (UID 1000) inside the container. Multi-architecture builds (linux/amd64 + linux/arm64) work via docker buildx and a docker-container driver — that's how the published GHCR image is produced.
Test suite runs with pytest. The project uses uv for environment and dependency management — install uv once (install guide), then:
uv sync # create venv, install locked deps
uv run pre-commit install # arm the quality-gate git hook
uv run pytest # run the testsWith a coverage report:
uv run pytest --cov=icloud_sync --cov-report=term-missingThe pre-commit hook runs Ruff (lint + format) and ty (type check) on every commit. See CONTRIBUTING.md for details.
Every filesystem test uses pytest's tmp_path fixture; every network call is stubbed via monkeypatch. The suite never touches Apple's real API or writes files outside the temp dir.
Coverage groups:
- Pure logic: URL parsing, best-derivative selection (photo, video, edge cases), collision-proof local filename generation, the managed-file naming regex.
- Shard resolution: happy-path 200, 330 redirect via response header, missing-host error path.
- Timeout policy: configured ceilings reach httpx; a slow-but-successful call warns past half its budget; a timeout escalates once and retries at double; a second timeout raises without a third attempt; escalation persists across calls while the endpoint stays slow and clears when it fits the base ceiling again; escalation is isolated per host and endpoint, so neither the fast shard probe nor
webasseturlscan clear whatwebstreamneeds. - Download resilience: one failed asset doesn't abort the cycle; partial files are removed; pruning still runs on a partially failed cycle; an all-downloads-failed cycle raises as systemic.
- Daemon backoff: a failed run sleeps
RETRY_SLEEP_MINUTESrather than the full interval, doubles on consecutive failures, caps at the interval, and resets on the first success. - End-to-end
sync_album: creates the output directory; downloads every asset at the manifest's declared size; skips unchanged files on re-run; re-downloads on size mismatch; prunes orphans that match the tool's naming pattern; leaves manual (non-matching) files alone; handles filename collisions across contributors; prunes assets removed from the album on the next sync; handles an empty manifest; whenAUTOPRUNE_ON_LOW_STORAGE=true, keeps the newest slice that fits under the disk budget and evicts older photos; whenfalse, refuses to touch disk on over-budget runs.
- Write contributor / caption / date sidecars. The API exposes all three; a future release will write them alongside the media as JSON or XMP.
- Handle private (non-public) Shared Albums. Only works with albums that have the "Public Website" toggle enabled.
Contributions are welcome — see CONTRIBUTING.md for setup, expectations, and the scope guide. Participation is governed by the Code of Conduct. Security issues should be reported privately per SECURITY.md.
Changes per release are tracked in CHANGELOG.md.
MIT — see LICENSE.
This is a community-supported open source project. Issues and pull requests are welcome; there is no SLA and no obligation to fix. If you find a bug, open an issue with the log output and (if you can share it) the album URL that triggered it.
Built and maintained by Bitwise Forge.
iCloud and Apple are trademarks of Apple Inc., registered in the U.S. and other countries. This project is not affiliated with, endorsed by, or sponsored by Apple Inc.