immich: add native S3 storage engine support for thumbnails/uploads

Adds an opt-in prompt to store Immich-managed data (thumbnails, encoded
video, new uploads) in S3-compatible object storage instead of local
disk, using Immich's native IMMICH_STORAGE_ENGINE=s3 — deliberately NOT
a FUSE-mounted bucket. Checked this against real reported issues before
implementing: Immich uses symlinks internally that S3 doesn't support
under FUSE (ENOSYS errors), and its startup does thousands of stat()/
read() calls that FUSE-over-network handles badly enough to crash the
mount under latency spikes as small as 100ms. Native S3 mode talks to
the bucket over the S3 API directly, sidestepping both problems.

Independent of the existing external-library strategy — an external
library (existing photos indexed read-only, e.g. over a VPN mount) is
a separate mount either way and works the same regardless of where
Immich's own managed data lives, since S3 mode only replaces
UPLOAD_LOCATION.

- New prompts: bucket, region, endpoint (for non-AWS S3-compatible
  providers — auto-sets S3_FORCE_PATH_STYLE when given), prefix, access
  key ID, and secret key (read via `read -rs` so it doesn't echo; left
  blank with a warning under UNATTENDED, since there's no sane default).
- Refactored the docker-compose.yml generation from two near-duplicate
  heredocs (with/without external library) into one with composable
  volume-line variables, to avoid quadrupling the duplication once S3
  was added as a second axis.
- Skips creating local upload-location subdirectories entirely in S3
  mode (thumbs/upload/backups/library/profile/encoded-video) — Immich
  manages that structure inside the bucket itself.
- .env now gets chmod 600 (previously ungated) — more pointed now that
  it can hold an S3 secret key, not just the DB password.
- Generated README documents the S3 setup and carries the FUSE-mount
  warning forward so a future reader doesn't try that route instead.

Verified both the non-S3 baseline (unchanged output) and S3 mode
end-to-end via non-interactive dry runs — correct .env, correct
compose volumes, no local upload dirs created, 0600 permissions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TBtExJcqxnokyZZKmphdug
This commit is contained in:
Claude
2026-08-09 20:06:50 +00:00
parent 033ffeee48
commit d2848cecc3
+124 -75
View File
@@ -198,6 +198,9 @@ install_immich() {
echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres"
echo " - Strategy 1 (unified): all photos in one folder, import-photos.sh helper"
echo " - Strategy 2 (external): existing photos indexed read-only, new uploads separate"
echo " - Optionally store thumbnails/encoded-video/new-uploads in S3-compatible"
echo " object storage instead of local disk (native IMMICH_STORAGE_ENGINE=s3 —"
echo " NOT a FUSE mount, those are unreliable for Immich's access pattern)"
echo " - Expose port 2283"
echo " - Offer a Caddy reverse proxy and to start the stack"
return 0
@@ -270,18 +273,68 @@ install_immich() {
echo ""
# ── S3-compatible object storage (thumbnails/encoded-video/new uploads) ──
# Native IMMICH_STORAGE_ENGINE=s3 support — talks to the S3 API directly,
# NOT a FUSE-mounted bucket pretending to be a filesystem. That distinction
# matters: Immich uses symlinks internally (S3 doesn't support them —
# ENOSYS errors under FUSE) and does thousands of stat()/read() calls on
# startup, which FUSE-over-network handles badly (reported to crash the
# mount under latency spikes as small as 100ms). Native S3 mode sidesteps
# both problems by never pretending the bucket is a filesystem.
#
# Independent of the library strategy above — an external library (if
# configured) is a separate read-only mount either way and is unaffected
# by where Immich's own managed data (thumbs/encoded-video/upload/backups/
# profile) lives.
echo ""
local USE_S3=""
prompt_yn "Store Immich-managed data (thumbnails, encoded video, new uploads) in S3-compatible object storage instead of local disk? (y/n):" "n" USE_S3
local S3_BUCKET="" S3_REGION="" S3_ENDPOINT="" S3_PREFIX=""
local S3_ACCESS_KEY_ID="" S3_SECRET_ACCESS_KEY="" S3_FORCE_PATH_STYLE=""
if [ "$USE_S3" = "y" ] || [ "$USE_S3" = "Y" ]; then
USE_S3=true
echo ""
echo " S3-COMPATIBLE OBJECT STORAGE"
echo ""
prompt_text " Bucket name:" "" S3_BUCKET
prompt_text " Region (blank if your provider doesn't use one):" "us-east-1" S3_REGION
prompt_text " Endpoint URL (blank = AWS S3; set for IONOS/MinIO/other S3-compatible providers):" "" S3_ENDPOINT
prompt_text " Prefix/folder within the bucket (blank = bucket root):" "" S3_PREFIX
prompt_text " Access key ID:" "" S3_ACCESS_KEY_ID
if [ "$UNATTENDED" = true ]; then
S3_SECRET_ACCESS_KEY=""
log_warning "Unattended mode — secret access key left blank. Set S3_SECRET_ACCESS_KEY in .env before starting."
else
read -r -sp " Secret access key: " S3_SECRET_ACCESS_KEY; echo ""
fi
[ -n "$S3_ENDPOINT" ] && S3_FORCE_PATH_STYLE=true
echo ""
echo " S3 mode: thumbnails, encoded video, and new uploads go to $S3_BUCKET."
[ -n "$EXTERNAL_LIBRARY" ] && echo " Existing photos ($EXTERNAL_LIBRARY) stay where they are, read-only, unaffected."
else
USE_S3=false
fi
echo ""
# ── Create directories ──────────────────────────────────────────────────
mkdir -p "$IMMICH_DIR"
ensure_docker_dir_ownership "$IMMICH_DIR"
mkdir -p "$UPLOAD_LOCATION"
[ -n "$EXTERNAL_LIBRARY" ] && mkdir -p "$EXTERNAL_LIBRARY"
# Immich checks for these subdirs + .immich marker files on startup
local subdir
for subdir in thumbs upload backups library profile encoded-video; do
mkdir -p "$UPLOAD_LOCATION/$subdir"
touch "$UPLOAD_LOCATION/$subdir/.immich"
done
if [ "$USE_S3" = true ]; then
log_info "S3 mode — skipping local upload-location directories; Immich manages"
log_info "thumbs/upload/backups/library/profile/encoded-video inside the bucket."
else
mkdir -p "$UPLOAD_LOCATION"
# Immich checks for these subdirs + .immich marker files on startup
local subdir
for subdir in thumbs upload backups library profile encoded-video; do
mkdir -p "$UPLOAD_LOCATION/$subdir"
touch "$UPLOAD_LOCATION/$subdir/.immich"
done
fi
cd "$IMMICH_DIR" || return 1
@@ -319,8 +372,18 @@ networks:
"
fi
if [ -n "$EXTERNAL_LIBRARY" ]; then
cat > docker-compose.yml << IMMICH_COMPOSE
# Composable volume lines instead of duplicating the whole compose file
# per combination — S3 mode drops the upload-location bind mount entirely
# (Immich talks to the bucket over the S3 API, nothing to mount), the
# external-library mount is independent and applies either way.
local _UPLOAD_VOLUME_LINE=" - \${UPLOAD_LOCATION}:/usr/src/app/upload
"
[ "$USE_S3" = true ] && _UPLOAD_VOLUME_LINE=""
local _EXTERNAL_VOLUME_LINE=""
[ -n "$EXTERNAL_LIBRARY" ] && _EXTERNAL_VOLUME_LINE=" - \${EXTERNAL_LIBRARY}:/usr/src/app/external:ro
"
cat > docker-compose.yml << IMMICH_COMPOSE
name: immich
services:
@@ -328,9 +391,7 @@ services:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:\${IMMICH_VERSION:-release}
volumes:
- \${UPLOAD_LOCATION}:/usr/src/app/upload
- \${EXTERNAL_LIBRARY}:/usr/src/app/external:ro
- /etc/localtime:/etc/localtime:ro
${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localtime:ro
env_file:
- .env
ports:
@@ -376,65 +437,31 @@ volumes:
model-cache:
${_CADDY_NET_SECTION}
IMMICH_COMPOSE
else
cat > docker-compose.yml << IMMICH_COMPOSE
name: immich
services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:\${IMMICH_VERSION:-release}
volumes:
- \${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
ports:
- 2283:2283
depends_on:
- redis
- database
restart: always
healthcheck:
disable: false
${_CADDY_NET_BLOCK}
immich-machine-learning:
container_name: immich_machine_learning
image: ghcr.io/immich-app/immich-machine-learning:\${IMMICH_VERSION:-release}
volumes:
- model-cache:/cache
env_file:
- .env
restart: always
healthcheck:
disable: false
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9-bookworm
healthcheck:
test: valkey-cli ping || exit 1
restart: always
database:
container_name: immich_postgres
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
environment:
POSTGRES_PASSWORD: \${DB_PASSWORD}
POSTGRES_USER: \${DB_USERNAME}
POSTGRES_DB: \${DB_DATABASE_NAME}
POSTGRES_INITDB_ARGS: '--data-checksums'
volumes:
- \${DB_DATA_LOCATION}:/var/lib/postgresql/data
restart: always
volumes:
model-cache:
${_CADDY_NET_SECTION}
IMMICH_COMPOSE
fi
# ── Write .env ──────────────────────────────────────────────────────────
local _UPLOAD_LOCATION_LINE="UPLOAD_LOCATION=$UPLOAD_LOCATION"
local _S3_BLOCK=""
if [ "$USE_S3" = true ]; then
# Do NOT set UPLOAD_LOCATION when using the S3 storage engine — Immich
# derives s3://<bucket>/<prefix> itself and the local bind mount is
# unused; leaving UPLOAD_LOCATION set alongside S3 vars is the
# documented footgun to avoid here.
_UPLOAD_LOCATION_LINE="# UPLOAD_LOCATION intentionally unset — S3 mode manages storage in the bucket"
_S3_BLOCK="
# S3-compatible object storage (thumbnails, encoded video, new uploads) —
# NOT a FUSE mount, this is Immich's native S3 storage engine talking to
# the bucket directly over the S3 API.
IMMICH_STORAGE_ENGINE=s3
S3_BUCKET=$S3_BUCKET
S3_REGION=$S3_REGION
S3_ENDPOINT=$S3_ENDPOINT
S3_PREFIX=$S3_PREFIX
S3_FORCE_PATH_STYLE=$S3_FORCE_PATH_STYLE
S3_ACCESS_KEY_ID=$S3_ACCESS_KEY_ID
S3_SECRET_ACCESS_KEY=$S3_SECRET_ACCESS_KEY
"
fi
if [ "$IMMICH_STRATEGY" = "2" ]; then
cat > .env << IMMICH_ENV
# IMMICH CONFIGURATION — External Library Mode
@@ -449,11 +476,11 @@ IMMICH_COMPOSE
# Click "Scan" to index your existing photos.
# New uploads from phone/web
UPLOAD_LOCATION=$UPLOAD_LOCATION
$_UPLOAD_LOCATION_LINE
# Existing photos (read-only, indexed by Immich)
EXTERNAL_LIBRARY=$EXTERNAL_LIBRARY
${_S3_BLOCK}
DB_DATA_LOCATION=./postgres
IMMICH_VERSION=release
DB_PASSWORD=$DB_PASS
@@ -471,7 +498,8 @@ IMMICH_ENV
#
# To import existing photos run: $IMMICH_DIR/import-photos.sh
UPLOAD_LOCATION=$UPLOAD_LOCATION
$_UPLOAD_LOCATION_LINE
${_S3_BLOCK}
DB_DATA_LOCATION=./postgres
IMMICH_VERSION=release
DB_PASSWORD=$DB_PASS
@@ -481,9 +509,10 @@ TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
IMMICH_ENV
fi
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR"
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPLOAD_LOCATION"
[ "$USE_S3" != true ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$UPLOAD_LOCATION"
[ -n "$EXTERNAL_LIBRARY" ] && chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EXTERNAL_LIBRARY" 2>/dev/null || true
# ── import-photos.sh (strategy 1 + existing photos only) ───────────────
@@ -773,9 +802,29 @@ Self-hosted photo and video backup — like Google Photos but private.
Mobile apps (iOS/Android) auto-upload in the background.
- Web UI: http://localhost:2283
- Photo storage: \`$UPLOAD_LOCATION\`
- Photo storage: $( [ "$USE_S3" = true ] && echo "S3 bucket \`$S3_BUCKET\` (thumbnails, encoded video, new uploads)" || echo "\`$UPLOAD_LOCATION\`" )
- App data (postgres, model cache): inside this folder
- Edit paths in \`.env\`, then \`docker compose up -d\` to apply.
- Edit paths/credentials in \`.env\`, then \`docker compose up -d\` to apply.
$( [ "$USE_S3" = true ] && cat << S3MD
## S3 object storage
Thumbnails, encoded video, and new uploads live in \`$S3_BUCKET\`
(this is Immich's native \`IMMICH_STORAGE_ENGINE=s3\`, talking to the S3 API
directly — **not** a FUSE-mounted bucket). Don't try to switch this to a
\`rclone mount\`/s3fs-style setup instead: Immich uses symlinks internally
that S3 doesn't support under FUSE (ENOSYS errors), and its startup alone
does thousands of stat()/read() calls that FUSE-over-network handles badly —
this has been reported to crash the mount under latency spikes as small as
100ms. The native S3 engine avoids both problems entirely.
$( [ -n "$EXTERNAL_LIBRARY" ] && echo "An external library is unaffected by this — it's a separate read-only mount (\`$EXTERNAL_LIBRARY\`) regardless of where Immich's own managed data lives." )
Credentials and bucket config are in \`.env\` (\`S3_*\` vars, \`chmod 600\`).
Changing them requires recreating the container:
\`\`\`bash
docker compose up -d --force-recreate immich-server
\`\`\`
S3MD
)
## Manage
\`\`\`bash