Skip to content

iCloud Photos Sync server setup

This page is for the administrator who installs and looks after iCloud Photos Sync. Once it’s set up, each person connects their own Apple account by following Import from iCloud Photos.

iCloud Photos Sync is off until you set it up. It needs:

  • a Frameleaf release that includes iCloud Photos Sync and its database changes (an older published image may not have it, so use a tested release that does),
  • a separate HTTPS bridge container that talks to Apple,
  • a bridge token, a private certificate and a session encryption key,
  • a private staging folder outside every media folder,
  • your Frameleaf web app served over HTTPS, because Apple sign-in is refused over plain HTTP.

The bridge is built from the icloud-bridge folder of the Frameleaf source and added to your installation with the deployment/icloud-sync.compose.yml overlay, so you need a checkout of the source that matches your release. The bridge is based on Rclone v1.75.1, with its Go modules and container base images pinned.

Add these to the environment file you use with Docker Compose, with absolute paths:

FRAMELEAF_SOURCE_ROOT=/opt/frameleaf-app
FRAMELEAF_IMAGE=your-registry/frameleaf-server:your-tested-release
FRAMELEAF_UID=1000
FRAMELEAF_GID=1000
ICLOUD_SECRETS_DIR=/srv/frameleaf-icloud/secrets
ICLOUD_STAGING_DIR=/srv/frameleaf-icloud/staging
Variable What it is
FRAMELEAF_SOURCE_ROOT Your checkout of the Frameleaf source. The bridge is built from it.
FRAMELEAF_IMAGE The tested Frameleaf server image tag or digest. Required.
FRAMELEAF_UID, FRAMELEAF_GID The non-root user and group that own your Frameleaf media files. Both the server and the bridge run as this identity. Defaults to 1000. Never set either to 0.
ICLOUD_SECRETS_DIR A private folder for the token, certificates and encryption key.
ICLOUD_STAGING_DIR A private folder for downloads in progress and verified recovery copies.

Your existing media folders must already be writable by that user; the overlay doesn’t change their ownership.

The older names IMMICH_FORK_IMAGE, IMMICH_UID, IMMICH_GID and IMMICH_SOURCE_ROOT still work in place of the FRAMELEAF_ names.

Run these once on the server, for a new installation. They don’t print the secret values.

Terminal window
umask 077
mkdir -p "$ICLOUD_SECRETS_DIR" "$ICLOUD_STAGING_DIR"
openssl rand -hex 32 > "$ICLOUD_SECRETS_DIR/bridge-token"
openssl rand -base64 32 > "$ICLOUD_SECRETS_DIR/encryption-key"
openssl req -x509 -newkey rsa:3072 -nodes -days 3650 \
-subj '/CN=Frameleaf iCloud private CA' \
-keyout "$ICLOUD_SECRETS_DIR/ca.key" -out "$ICLOUD_SECRETS_DIR/ca.crt"
openssl req -newkey rsa:3072 -nodes -subj '/CN=icloud-bridge' \
-keyout "$ICLOUD_SECRETS_DIR/bridge.key" -out "$ICLOUD_SECRETS_DIR/bridge.csr"
printf 'subjectAltName=DNS:icloud-bridge\nextendedKeyUsage=serverAuth\nbasicConstraints=CA:FALSE\n' > "$ICLOUD_SECRETS_DIR/bridge.ext"
openssl x509 -req -days 365 -in "$ICLOUD_SECRETS_DIR/bridge.csr" \
-CA "$ICLOUD_SECRETS_DIR/ca.crt" -CAkey "$ICLOUD_SECRETS_DIR/ca.key" -CAcreateserial \
-extfile "$ICLOUD_SECRETS_DIR/bridge.ext" -out "$ICLOUD_SECRETS_DIR/bridge.crt"
sudo chown -R "$FRAMELEAF_UID:$FRAMELEAF_GID" "$ICLOUD_SECRETS_DIR" "$ICLOUD_STAGING_DIR"
chmod 700 "$ICLOUD_SECRETS_DIR" "$ICLOUD_STAGING_DIR"
chmod 600 "$ICLOUD_SECRETS_DIR"/*
  • Never regenerate the encryption key when you update containers. Saved Apple sessions need the original key. The server expects a base64-encoded 32-byte key, not a password.
  • Keep the CA signing key (ca.key) offline once the bridge certificate is issued.
  • The bridge certificate lasts 365 days. Renew it before it expires.
  • On many systems Compose secrets are bind mounts, so the ownership and permissions on the host are what count.

The bridge only receives its token and TLS files. The server receives the token, the CA certificate and the encryption key. Apple passwords and verification codes are only ever typed into the HTTPS web form: never put them in .env, Compose files, command lines or support logs.

Check the combined configuration, then start it, using your release’s own Compose file:

Terminal window
docker compose --env-file /absolute/path/to/frameleaf.env \
-f /absolute/path/to/docker-compose.yml \
-f "$FRAMELEAF_SOURCE_ROOT/deployment/icloud-sync.compose.yml" config --quiet
docker compose --env-file /absolute/path/to/frameleaf.env \
-f /absolute/path/to/docker-compose.yml \
-f "$FRAMELEAF_SOURCE_ROOT/deployment/icloud-sync.compose.yml" up -d --build

The bridge has no published port and no access to your media. It sits on its own private Docker network, which allows outgoing HTTPS to Apple. Don’t give it host networking or a public reverse-proxy route.

The overlay sets these server variables for you:

Variable Set to
FRAMELEAF_ICLOUD_BRIDGE_URL https://icloud-bridge:9443
FRAMELEAF_ICLOUD_BRIDGE_TOKEN_FILE /run/secrets/icloud_bridge_token
FRAMELEAF_ICLOUD_KEY_FILE /run/secrets/icloud_encryption_key
FRAMELEAF_ICLOUD_CA_FILE /run/secrets/icloud_bridge_ca
FRAMELEAF_ICLOUD_STAGING_PATH /var/lib/immich-icloud-staging

If you run separate API and worker containers, give each the same bridge and key settings and the same access to the staging folder. See Workers.

The first start after upgrading to a release with iCloud source identities builds an index over the sync’s records. On a very large iCloud library, that start takes longer.

From the server container, check the bridge’s certificate and health response. Use the same Compose files and environment file as above in front of exec:

Terminal window
docker compose exec immich-server node --input-type=module -e '
import https from "node:https";
import fs from "node:fs";
https.get(new URL("/health", process.env.FRAMELEAF_ICLOUD_BRIDGE_URL), {
ca: fs.readFileSync(process.env.FRAMELEAF_ICLOUD_CA_FILE)
}, response => {
if (response.statusCode !== 200) process.exitCode = 1;
response.pipe(process.stdout);
}).on("error", () => { console.error("Bridge TLS/readiness check failed"); process.exitCode = 1; });
'

The response names protocol version 1 and the pinned Rclone source. It proves the bridge is running and trusted, not that Apple sign-in or an import works.

Each connection can use 1 to 4 concurrent downloads and its own staging budget. These server-wide limits apply on top, on the workers that run the sync:

Variable Default What it does
FRAMELEAF_ICLOUD_MAX_CONCURRENCY 4 Most downloads running at once across all connections. Also the highest concurrency a person can choose.
FRAMELEAF_ICLOUD_MAX_STAGING_BYTES 107374182400 (100 GiB) Total staging space all connections can reserve. Also the largest staging budget a person can choose.
FRAMELEAF_ICLOUD_FREE_SPACE_BYTES 1073741824 (1 GiB) Free disk space always left over, on top of the next download.
FRAMELEAF_ICLOUD_IDENTITY_MATCHING on Set to false to stop matching iCloud source identities between the sync and the iPhone app. Then only identical files count as the same.

Raising a limit doesn’t create disk space. Verified recovery copies outside a person’s current selection still use their reservation until they’re reselected and recovered, or you add capacity. Never clear reservations in the database or empty the staging folder to get round a full budget.

Every downloaded file is validated before it’s used, and videos are fully decoded. FRAMELEAF_MEDIA_VALIDATION_TIMEOUT_MS sets how long that may take. The default is 120000 (two minutes); values are kept between 10000 and 86400000, and an invalid value uses the default. Set it on every server or worker that validates media, for example in an extra Compose override:

services:
immich-server:
environment:
FRAMELEAF_MEDIA_VALIDATION_TIMEOUT_MS: '600000'

A timeout means the file couldn’t be checked, not that it’s damaged or repaired. A longer timeout lets long videos finish, but doesn’t add support for formats the decoder can’t read.

Disconnect removes a connection’s saved Apple session and stops new work. Imported photos stay, and nothing is deleted from the Apple account. Don’t empty the staging folder while a sync is running, or while it holds the only verified recovery copy of something.

Back these up together, as one consistent checkpoint:

  • the Frameleaf database,
  • your media folders,
  • the staging folder,
  • the encryption key.

Pause the sync workers before an application-consistent snapshot, and protect the backups as carefully as the secrets themselves. Keep the bridge token and TLS files recoverable too, and restore their permissions before starting the services. If you restore a database without its matching encryption key, everyone has to sign in to Apple again. Never attach session values, tokens, signed links, passwords or keys to a support report.

See Backup and restore for backing up the rest of your server.

Try one small album first and check:

  1. sign-in and device approval,
  2. a normal photo and a Live Photo,
  3. a photo with an Apple edit, which should appear in a stack with its original,
  4. a second sync of the same album, which should import nothing new,
  5. pausing and resuming.

Test recovery only with throwaway media, never a valuable original. A health response or a container build doesn’t prove that an Apple sync works.

If you switch to the official Immich server

Section titled “If you switch to the official Immich server”

iCloud Photos Sync data lives in Frameleaf’s own part of the database. During a certified handoff to the official server it’s left untouched and unused. When you come back, links to photos and albums that were deleted in the meantime are archived and cleared, the iCloud records themselves stay, and connections belonging to deleted users are turned off. See Going back to Immich.