Files
ubuntu-post-install/services/immich.sh
T
Claude 879cb24d3b Wire Authelia SSO into Immich, Audiobookshelf, and Beszel
_authelia_provision_oidc_client gains an optional PKCE flag (new 5th
positional arg; every existing caller updated to pass "n", producing an
identical client block to before) — Audiobookshelf and Beszel's own
Authelia integration docs both require require_pkce/pkce_challenge_method,
which Authelia doesn't turn on by default.

immich.sh: _immich_offer_authelia_oidc() is real server-side automation,
not just paste-in instructions — confirmed the exact system-config "oauth"
JSON field names against Immich's own config-file.md and source (not
guessed, closing out the "needs one more verification pass" note this
repo's own CLAUDE.md already had on file). GET/PUT exchange the whole
config object, so it round-trips everything else unchanged. Needs an
admin API key that doesn't exist until first web-UI visit, so it's wired
into both the fresh-install path and the "update" rerun path.

audiobookshelf.sh, beszel.sh: both apps' OIDC config is UI-only (checked
against audiobookshelf.org and beszel.dev directly — no config API or env
var for the provider fields), so their new offers automate the Authelia
side and print exact paste-in values. Beszel also gets a real, separate
DISABLE_PASSWORD_AUTH/USER_CREATION toggle to fully replace its login,
gated behind a warning to register a working account first.

Also adds Audiobookshelf and Beszel as presets in authelia.sh's own
generic "Register another app" menu, and updates CLAUDE.md's OIDC
verification table to match reality (Immich now wired, Audiobookshelf
was wrongly listed as "high-confidence no", Beszel added).
2026-08-24 16:08:33 +00:00

1105 lines
50 KiB
Bash

#!/bin/bash
# services/immich.sh — Self-hosted photo & video backup (like Google Photos).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash immich.sh
# (Docker must already be installed when run standalone)
#
# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- IMMICH ----).
# Multi-container stack: immich-server + machine-learning + valkey + postgres.
# Two library strategies:
# 1) Unified — Immich manages all photos in one place (import-photos.sh helps)
# 2) External — Immich indexes your existing folder read-only; new uploads separate
# ── Standalone bootstrap ──────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
source "$_COMMON"
else
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
# Determine mode: local Caddy, remote Caddy, or none
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
# Domain prompt — pre-fill from SITE_DOMAIN when available
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
# Build upstream — remote Caddy uses host IP:port, not container name
local _block_upstream="$_upstream"
if [[ "$_mode" == "remote" ]]; then
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
fi
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
reverse_proxy ${_block_upstream}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
${_extra}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
fi
else
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
mkdir -p "$_snippet_dir"
printf '%s\n' "$_site_block" > "$_snippet_file"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
log_success "Snippet saved: $_snippet_file"
log_info "Copy to Caddy machine:"
log_info " scp $_snippet_file caddy-host:~/caddy-snippets/"
log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)"
fi
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
fi
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
register_service() { :; }
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service immich media "Self-hosted photo & video backup — like Google Photos (Immich)" 2283
# Offers to wire Immich's own native OAuth support to Authelia — real
# server-side automation, not just paste-in instructions, unlike
# Audiobookshelf/Beszel below (neither exposes a config API; Immich does).
# Confirmed against docs.mealie.io's sibling page for Immich
# (docs.immich.app/administration/oauth) and, since that page doesn't
# document the underlying API, against Immich's own config-file.md and
# GitHub source directly for the exact JSON field names under the "oauth"
# key — not guessed. GET/PUT /api/system-config exchanges the WHOLE config
# object (there's no partial-patch endpoint), so this only ever touches the
# "oauth" sub-object and round-trips everything else completely unchanged
# — the same GET-modify-PUT shape already proven in this file for the
# storage-template step in import-photos.sh (search CURRENT_CONFIG above).
#
# Unlike Mealie/ActualBudget/Gitea, Immich's admin account isn't created by
# this installer — the user creates it themselves on first web visit (see
# "First launch" in the generated README) — so there's no API key to call
# with at the moment a FRESH install finishes. This is deliberately called
# from both the fresh-install path (where it'll usually just tell you to
# come back later) and the "update" rerun path (the realistic way most
# people actually complete this, once they have an account), same as
# _mealie_offer_authelia_oidc's own "works from either" design.
#
# Args: IMMICH_DIR WEB_PORT
_immich_offer_authelia_oidc() {
local DIR="$1" WEB_PORT="$2"
[ -d "$DOCKER_DIR/authelia" ] || return 0
declare -F _authelia_provision_oidc_client >/dev/null 2>&1 || return 0
echo ""
local USE_SSO=""
prompt_yn " Add \"Sign in with Authelia\" (OpenID Connect) to Immich? (y/n):" "n" USE_SSO
[[ "$USE_SSO" =~ ^[Yy]$ ]] || return 0
echo " This writes Immich's OAuth settings for you via its own API — needs an"
echo " admin API key: Administration -> Settings -> API Keys -> New API Key"
echo " (Admin scope). Leave blank to skip for now — safe to come back to this"
echo " later by re-running 'sudo ./setup.sh immich' once you have one."
local IMMICH_API_KEY=""
prompt_text " Immich admin API key:" "" IMMICH_API_KEY
if [ -z "$IMMICH_API_KEY" ]; then
log_info "Skipped — no account/API key yet. Come back to this by re-running"
log_info "'sudo ./setup.sh immich' (choose \"Manage that install\" -> update)."
return 0
fi
local IMMICH_LOCAL_URL="http://localhost:${WEB_PORT}"
local VERIFY_CODE
VERIFY_CODE="$(curl -s -o /dev/null -w '%{http_code}' -H "x-api-key: $IMMICH_API_KEY" "$IMMICH_LOCAL_URL/api/users/me" 2>/dev/null)"
if [ "$VERIFY_CODE" != "200" ]; then
log_warning "Couldn't verify that API key against Immich (HTTP $VERIFY_CODE) — skipping SSO setup."
return 0
fi
local APP_DOMAIN
APP_DOMAIN="$(_authelia_pick_domain "Domain Immich is reachable at (number or domain)")"
if [ -z "$APP_DOMAIN" ]; then
log_warning "No domain entered — skipping SSO setup."
return 0
fi
local _2fa="" AUTH_POLICY="two_factor"
prompt_yn " Require two-factor for Immich logins via Authelia too? (y/n):" "y" _2fa
[[ "$_2fa" =~ ^[Yy]$ ]] || AUTH_POLICY="one_factor"
# Same three redirect URIs as the "Immich" preset in authelia.sh's own
# generic OIDC menu (web login, account-linking, mobile app callback) —
# kept identical on purpose so either path produces the same client.
if ! _authelia_provision_oidc_client "Immich" "immich" "$AUTH_POLICY" "y" "n" \
"https://${APP_DOMAIN}/auth/login" "https://${APP_DOMAIN}/user-settings" "app.immich:///oauth-callback"; then
log_warning "Couldn't register Immich as an OIDC client in Authelia — skipping SSO setup."
return 0
fi
local _client_secret="$OIDC_CLIENT_SECRET_PLAIN" _portal_url="$OIDC_AUTHELIA_PORTAL_URL"
local CURRENT_CONFIG
CURRENT_CONFIG="$(curl -s -H "x-api-key: $IMMICH_API_KEY" "$IMMICH_LOCAL_URL/api/system-config" 2>/dev/null)"
if [ -z "$CURRENT_CONFIG" ] || ! command -v python3 &>/dev/null; then
log_warning "Couldn't read Immich's system config — set OAuth manually instead:"
echo " Administration -> Settings -> OAuth Authentication"
echo " Issuer URL: ${_portal_url}"
echo " Client ID: immich"
echo " Client Secret: ${_client_secret}"
return 0
fi
# Secret/issuer are passed via env vars, not interpolated into the
# python source as string literals — Authelia's generated secret uses
# an rfc3986 charset that isn't guaranteed free of characters (a stray
# quote, say) that would otherwise break out of a quoted Python literal.
local UPDATED_CONFIG
UPDATED_CONFIG="$(echo "$CURRENT_CONFIG" | OIDC_SECRET="$_client_secret" OIDC_ISSUER="$_portal_url" python3 -c "
import sys, json, os
config = json.load(sys.stdin)
config['oauth']['enabled'] = True
config['oauth']['issuerUrl'] = os.environ['OIDC_ISSUER']
config['oauth']['clientId'] = 'immich'
config['oauth']['clientSecret'] = os.environ['OIDC_SECRET']
config['oauth']['scope'] = 'openid email profile'
config['oauth']['buttonText'] = 'Login with Authelia'
json.dump(config, sys.stdout)
" 2>/dev/null)"
if [ -z "$UPDATED_CONFIG" ]; then
log_warning "Couldn't parse Immich's config — set OAuth manually: Administration -> Settings -> OAuth Authentication"
echo " Issuer URL: ${_portal_url} Client ID: immich Client Secret: ${_client_secret}"
return 0
fi
local RESULT
RESULT="$(curl -s -o /dev/null -w '%{http_code}' -X PUT \
-H "x-api-key: $IMMICH_API_KEY" -H "Content-Type: application/json" \
"$IMMICH_LOCAL_URL/api/system-config" -d "$UPDATED_CONFIG" 2>/dev/null)"
if [ "$RESULT" = "200" ]; then
log_success "\"Sign in with Authelia\" enabled in Immich — local login still works too."
else
log_warning "Couldn't set Immich's OAuth config (HTTP $RESULT) — set it manually instead:"
echo " Administration -> Settings -> OAuth Authentication"
echo " Issuer URL: ${_portal_url} Client ID: immich Client Secret: ${_client_secret}"
fi
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "immich" "$APP_DOMAIN"
}
install_immich() {
require_docker || return 1
# ── Instance selection ───────────────────────────────────────────────────
# First instance keeps the plain "immich" name/paths/port and
# immich_server/immich_machine_learning/immich_redis/immich_postgres
# container names exactly as before (zero behavior change for anyone with
# a single instance). Only asking to add a second one introduces suffixed
# naming — same pattern as services/mattermost.sh and services/wordpress.sh.
# Each instance gets its own dedicated Postgres (already the case — one
# per compose project) and its own model-cache volume (scoped by the
# per-instance Compose project name), so instances are fully isolated for
# backup/restore too, per CLAUDE.md's "Multi-instance services" section.
local IMMICH_DIR="$DOCKER_DIR/immich"
local INSTANCE_SUFFIX="" PROJECT="immich"
local C_SERVER="immich_server" C_ML="immich_machine_learning" C_REDIS="immich_redis" C_DB="immich_postgres"
local WEB_PORT="2283"
local DEFAULT_PHOTOS="$ACTUAL_HOME/photos"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Immich would:"
echo " - Offer to add a new, separate instance if one already exists"
echo " - Create \$DOCKER_DIR/immich(-<name>) with docker-compose.yml + .env"
echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres (dedicated per instance)"
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, auto-scanned for additional instances"
echo " - Offer a Caddy reverse proxy and to start the stack"
return 0
fi
if [ -d "$IMMICH_DIR" ]; then
echo ""
echo " Immich is already installed at $IMMICH_DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate Immich instance alongside it (its own"
echo " server, database, and port — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'kids'):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
if [ -z "$_suffix" ]; then
log_warning "Name can't be empty."; continue
fi
if [ -d "$DOCKER_DIR/immich-$_suffix" ]; then
log_warning "immich-$_suffix already exists — pick another name."; continue
fi
break
done
INSTANCE_SUFFIX="$_suffix"
IMMICH_DIR="$DOCKER_DIR/immich-$_suffix"
PROJECT="immich-$_suffix"
C_SERVER="immich_server_$_suffix"
C_ML="immich_ml_$_suffix"
C_REDIS="immich_redis_$_suffix"
C_DB="immich_postgres_$_suffix"
DEFAULT_PHOTOS="$ACTUAL_HOME/photos-$_suffix"
log_info "New instance: $IMMICH_DIR"
else
# "Manage that install" on THIS instance — the banner above promises
# update/fresh/cancel, so actually offer it instead of falling straight
# through into the same unconditional-overwrite flow as a new install.
if [[ -f "$IMMICH_DIR/docker-compose.yml" ]]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
log_info "Refreshing the Immich image only — existing config, port, and Caddy setup are left as-is."
( cd "$IMMICH_DIR" && docker compose pull && docker compose up -d ) \
&& log_success "Immich image refreshed" \
|| log_warning "Refresh failed — check: docker compose -f $IMMICH_DIR/docker-compose.yml logs"
# WEB_PORT isn't persisted anywhere but the compose
# file's own port mapping — re-derive it here rather
# than assuming the "2283" default this local started
# with, which may not match if it was shifted at
# install time (collision avoidance / another instance).
local _EXISTING_PORT
_EXISTING_PORT="$(grep -oP '^\s+- "?\K[0-9]+(?=:2283)' "$IMMICH_DIR/docker-compose.yml" 2>/dev/null | head -1)"
[ -n "$_EXISTING_PORT" ] && WEB_PORT="$_EXISTING_PORT"
_immich_offer_authelia_oidc "$IMMICH_DIR" "$WEB_PORT"
return 0
;;
cancel)
log_info "Leaving the existing install as-is."
return 0
;;
fresh) ;; # fall through to the full install flow below
esac
fi
fi
fi
# Scan for a free port unconditionally — not just when adding an explicit
# additional instance. A plain first install can just as easily collide
# with an unrelated service that already claimed this default port — see
# CLAUDE.md's "Port collision avoidance" section.
find_free_port WEB_PORT "$WEB_PORT"
# ── Photo library setup ─────────────────────────────────────────────────
echo ""
echo " PHOTO LIBRARY SETUP"
echo ""
local IMMICH_STRATEGY="1" UPLOAD_LOCATION="" EXTERNAL_LIBRARY="" EXISTING_PHOTOS_SOURCE=""
local HAS_EXISTING_PHOTOS=""
prompt_yn "Do you have existing photos to include? (y/n):" "n" HAS_EXISTING_PHOTOS
if [ "$HAS_EXISTING_PHOTOS" = "y" ] || [ "$HAS_EXISTING_PHOTOS" = "Y" ]; then
echo ""
echo " How should Immich handle your existing photos?"
echo ""
echo " [1] Import into Immich (recommended)"
echo " Immich manages all photos in one unified library."
echo " Dates preserved via EXIF. Organized by date automatically."
echo " Your original folder names are NOT kept on disk"
echo " (use Immich albums to organize instead)."
echo ""
echo " [2] Keep existing photos in place (read-only external library)"
echo " Immich indexes your existing photos without moving them."
echo " New uploads go to a separate folder."
echo " Your folder structure stays intact."
echo ""
if [ "$UNATTENDED" = true ]; then
IMMICH_STRATEGY="1"
echo " Strategy: [auto: 1]"
else
read -r -p " Choose [1/2]: " IMMICH_STRATEGY
IMMICH_STRATEGY="${IMMICH_STRATEGY:-1}"
fi
if [ "$IMMICH_STRATEGY" = "2" ]; then
local EXISTING_PHOTOS_PATH=""
prompt_text "Existing photos path [$DEFAULT_PHOTOS]:" "$DEFAULT_PHOTOS" EXISTING_PHOTOS_PATH
EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_PATH/#\~/$ACTUAL_HOME}"; EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}"
UPLOAD_LOCATION="$ACTUAL_HOME/immich-uploads"
EXTERNAL_LIBRARY="$EXISTING_PHOTOS_SOURCE"
echo ""
echo " Setup:"
echo " Existing photos: $EXISTING_PHOTOS_SOURCE (read-only)"
echo " New uploads: $UPLOAD_LOCATION"
else
local PHOTOS_DIR_INPUT=""
prompt_text "Photo library path [$DEFAULT_PHOTOS]:" "$DEFAULT_PHOTOS" PHOTOS_DIR_INPUT
PHOTOS_DIR_INPUT="${PHOTOS_DIR_INPUT/#\~/$ACTUAL_HOME}"; PHOTOS_DIR_INPUT="${PHOTOS_DIR_INPUT%/}"
local EXISTING_INPUT=""
prompt_text "Existing photos path [$PHOTOS_DIR_INPUT]:" "$PHOTOS_DIR_INPUT" EXISTING_INPUT
EXISTING_PHOTOS_SOURCE="${EXISTING_INPUT/#\~/$ACTUAL_HOME}"; EXISTING_PHOTOS_SOURCE="${EXISTING_PHOTOS_SOURCE%/}"
UPLOAD_LOCATION="$PHOTOS_DIR_INPUT"
echo ""
echo " All photos (existing + new) will live in: $PHOTOS_DIR_INPUT"
fi
else
local PHOTOS_DIR_INPUT=""
prompt_text "Photo library path [$DEFAULT_PHOTOS]:" "$DEFAULT_PHOTOS" PHOTOS_DIR_INPUT
PHOTOS_DIR_INPUT="${PHOTOS_DIR_INPUT/#\~/$ACTUAL_HOME}"; PHOTOS_DIR_INPUT="${PHOTOS_DIR_INPUT%/}"
UPLOAD_LOCATION="$PHOTOS_DIR_INPUT"
echo ""
echo " Photos will be stored in: $PHOTOS_DIR_INPUT"
fi
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"
[ -n "$EXTERNAL_LIBRARY" ] && mkdir -p "$EXTERNAL_LIBRARY"
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
# ── Generate DB password ────────────────────────────────────────────────
# Reused across reruns if already set — the Postgres volume keeps the
# password from its first init, so a fresh random one on every rerun
# would lock Immich out of its own database.
local DB_PASS TZ_VAL
DB_PASS=""
[ -f ".env" ] && DB_PASS="$(grep '^DB_PASSWORD=' .env | cut -d= -f2-)"
[ -n "$DB_PASS" ] || DB_PASS="$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32)"
TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
# ── Write docker-compose.yml ────────────────────────────────────────────
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
# explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy,
# then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net — a
# remote Caddy box can't resolve container names on this host's bridge
# network anyway; it reaches this service via the host's published port.
local _CADDY_MODE="${CADDY_MODE:-none}"
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
local _CADDY_NET_BLOCK=""
local _CADDY_NET_SECTION=""
if [ "$_CADDY_MODE" = "local" ]; then
_CADDY_NET_BLOCK=" networks:
- caddy_net
"
_CADDY_NET_SECTION="
networks:
caddy_net:
external: true
name: ${SITE_CADDY_NET:-caddy_net}
"
fi
# 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: $PROJECT
services:
immich-server:
container_name: $C_SERVER
image: ghcr.io/immich-app/immich-server:\${IMMICH_VERSION:-release}
volumes:
${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localtime:ro
env_file:
- .env
ports:
- ${WEB_PORT}:2283
depends_on:
- redis
- database
restart: always
healthcheck:
disable: false
${_CADDY_NET_BLOCK}
immich-machine-learning:
container_name: $C_ML
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: $C_REDIS
image: docker.io/valkey/valkey:9-bookworm
healthcheck:
test: valkey-cli ping || exit 1
restart: always
database:
container_name: $C_DB
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
# ── 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
#
# STORAGE TEMPLATE (set in Immich web UI):
# Admin → Settings → Storage Template → Enable
# Template: {{y}}/{{MM}}/{{filename}}
#
# EXTERNAL LIBRARY SETUP:
# Admin → External Libraries → Create Library
# Import path: /usr/src/app/external
# Click "Scan" to index your existing photos.
# New uploads from phone/web
$_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
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
IMMICH_ENV
else
cat > .env << IMMICH_ENV
# IMMICH CONFIGURATION — Unified Library
#
# All photos (imported + new uploads) are stored in one location.
# Storage template organizes files by date automatically.
#
# To import existing photos run: $IMMICH_DIR/import-photos.sh
$_UPLOAD_LOCATION_LINE
${_S3_BLOCK}
DB_DATA_LOCATION=./postgres
IMMICH_VERSION=release
DB_PASSWORD=$DB_PASS
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET
IMMICH_ENV
fi
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR"
[ "$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) ───────────────
if [ "$IMMICH_STRATEGY" != "2" ] && [ -n "$EXISTING_PHOTOS_SOURCE" ]; then
cat > "$IMMICH_DIR/import-photos.sh" << 'IMPORT_HEAD'
#!/bin/bash
################################################################################
# Immich Photo Import Script — generated by ubuntu-post-install
#
# Imports your existing photo collection into Immich with EXIF date preservation.
# Photos are uploaded through the API so Immich extracts metadata (dates, GPS,
# camera info) from the originals.
#
# What this script does:
# 1. Creates admin account (if first run) or logs in
# 2. Generates an API key automatically
# 3. Configures the storage template (date-based organization)
# 4. Installs the Immich CLI (if needed)
# 5. Uploads all photos with EXIF metadata preserved
#
# Usage:
# ./import-photos.sh # interactive (prompts for everything)
# ./import-photos.sh <api-key> # skip account setup, use existing key
################################################################################
IMPORT_HEAD
cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_VARS
IMMICH_URL="http://localhost:${WEB_PORT}"
SOURCE_DIR="$EXISTING_PHOTOS_SOURCE"
IMMICH_DIR="$IMMICH_DIR"
IMPORT_VARS
cat >> "$IMMICH_DIR/import-photos.sh" << 'IMPORT_BODY'
echo ""
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ IMMICH PHOTO IMPORT │"
echo "└─────────────────────────────────────────────────────────────────┘"
echo ""
# ── Preflight checks ────────────────────────────────────────────────────────
echo "Checking Immich server..."
if ! curl -s "$IMMICH_URL/api/server/ping" > /dev/null 2>&1; then
echo ""
echo " ✗ Immich is not running at $IMMICH_URL"
echo " Start it with: cd $IMMICH_DIR && docker compose up -d"
echo ""
exit 1
fi
echo " ✓ Immich is running"
if [ ! -d "$SOURCE_DIR" ]; then
echo ""
echo " ✗ Source directory not found: $SOURCE_DIR"
echo " Update SOURCE_DIR in this script if your photos moved."
echo ""
exit 1
fi
echo " ✓ Source directory: $SOURCE_DIR"
echo -n " Scanning for photos/videos..."
PHOTO_COUNT=$(find "$SOURCE_DIR" -type f \( \
-iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.heic" -o \
-iname "*.heif" -o -iname "*.webp" -o -iname "*.gif" -o -iname "*.tiff" -o \
-iname "*.bmp" -o -iname "*.mp4" -o -iname "*.mov" -o -iname "*.avi" -o \
-iname "*.mkv" -o -iname "*.webm" \) 2>/dev/null | wc -l)
echo " done"
echo " ✓ Found ~$PHOTO_COUNT photos/videos"
# ── Get or create API key ────────────────────────────────────────────────────
API_KEY="${1:-}"
if [ -z "$API_KEY" ]; then
echo ""
SERVER_CONFIG=$(curl -s "$IMMICH_URL/api/server/config" 2>/dev/null)
IS_INITIALIZED=$(echo "$SERVER_CONFIG" | python3 -c \
"import sys,json; print(json.load(sys.stdin).get('isInitialized', True))" 2>/dev/null)
if [ "$IS_INITIALIZED" = "False" ]; then
echo "┌─────────────────────────────────────────────────────────────────┐"
echo "│ FIRST-TIME SETUP — Creating admin account │"
echo "└─────────────────────────────────────────────────────────────────┘"
echo ""
read -r -p " Admin email: " ADMIN_EMAIL
while [ -z "$ADMIN_EMAIL" ]; do
read -r -p " Admin email (required): " ADMIN_EMAIL
done
read -r -sp " Admin password: " ADMIN_PASS; echo ""
while [ "${#ADMIN_PASS}" -lt 8 ]; do
echo " Password must be at least 8 characters."
read -r -sp " Admin password: " ADMIN_PASS; echo ""
done
read -r -p " Your name [Admin]: " ADMIN_NAME
ADMIN_NAME="${ADMIN_NAME:-Admin}"
echo ""
echo " Creating admin account..."
SIGNUP_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
"$IMMICH_URL/api/auth/admin-sign-up" \
-d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\",\"name\":\"$ADMIN_NAME\"}" 2>/dev/null)
SIGNUP_CODE=$(echo "$SIGNUP_RESPONSE" | tail -1)
SIGNUP_BODY=$(echo "$SIGNUP_RESPONSE" | sed '$d')
if [ "$SIGNUP_CODE" = "201" ]; then
echo " ✓ Admin account created"
else
echo " ✗ Failed to create admin account (HTTP $SIGNUP_CODE)"
echo " Response: $SIGNUP_BODY"
echo " Create your account at $IMMICH_URL then re-run: $0 <api-key>"
exit 1
fi
else
echo " Immich is already set up. Log in to generate an API key."
echo ""
read -r -p " Admin email: " ADMIN_EMAIL
while [ -z "$ADMIN_EMAIL" ]; do
read -r -p " Admin email (required): " ADMIN_EMAIL
done
read -r -sp " Admin password: " ADMIN_PASS; echo ""
fi
echo " Logging in..."
LOGIN_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
"$IMMICH_URL/api/auth/login" \
-d "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\"}" 2>/dev/null)
LOGIN_CODE=$(echo "$LOGIN_RESPONSE" | tail -1)
LOGIN_BODY=$(echo "$LOGIN_RESPONSE" | sed '$d')
if [ "$LOGIN_CODE" != "201" ]; then
echo " ✗ Login failed (HTTP $LOGIN_CODE)"
echo " Check your email/password, or pass an API key: $0 <api-key>"
exit 1
fi
ACCESS_TOKEN=$(echo "$LOGIN_BODY" | python3 -c \
"import sys,json; print(json.load(sys.stdin)['accessToken'])" 2>/dev/null)
[ -z "$ACCESS_TOKEN" ] && { echo " ✗ Could not extract access token"; exit 1; }
echo " ✓ Logged in"
echo " Creating API key..."
APIKEY_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
"$IMMICH_URL/api/api-keys" \
-d '{"name":"import-photos-script"}' 2>/dev/null)
APIKEY_CODE=$(echo "$APIKEY_RESPONSE" | tail -1)
APIKEY_BODY=$(echo "$APIKEY_RESPONSE" | sed '$d')
if [ "$APIKEY_CODE" = "201" ]; then
API_KEY=$(echo "$APIKEY_BODY" | python3 -c \
"import sys,json; print(json.load(sys.stdin)['secret'])" 2>/dev/null)
if [ -n "$API_KEY" ]; then
echo " ✓ API key created"
else
echo " ✗ Could not extract API key"
echo " Create one at $IMMICH_URL → Account Settings → API Keys"
echo " Then re-run: $0 <api-key>"
exit 1
fi
else
echo " ✗ Failed to create API key (HTTP $APIKEY_CODE)"
echo " Create one at $IMMICH_URL → Account Settings → API Keys"
echo " Then re-run: $0 <api-key>"
exit 1
fi
else
echo ""
echo " Verifying API key..."
VERIFY_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
-H "x-api-key: $API_KEY" "$IMMICH_URL/api/users/me" 2>/dev/null)
[ "$VERIFY_CODE" != "200" ] && { echo " ✗ Invalid API key (HTTP $VERIFY_CODE)"; exit 1; }
echo " ✓ API key valid"
fi
# ── Configure storage template ───────────────────────────────────────────────
echo ""
echo " Configuring storage template ({{y}}/{{MM}}/{{filename}})..."
CURRENT_CONFIG=$(curl -s -H "x-api-key: $API_KEY" "$IMMICH_URL/api/system-config" 2>/dev/null)
if [ -n "$CURRENT_CONFIG" ] && command -v python3 &>/dev/null; then
UPDATED_CONFIG=$(echo "$CURRENT_CONFIG" | python3 -c "
import sys, json
config = json.load(sys.stdin)
config['storageTemplate']['enabled'] = True
config['storageTemplate']['template'] = '{{y}}/{{MM}}/{{filename}}'
json.dump(config, sys.stdout)
" 2>/dev/null)
if [ -n "$UPDATED_CONFIG" ]; then
RESULT=$(curl -s -o /dev/null -w "%{http_code}" -X PUT \
-H "x-api-key: $API_KEY" \
-H "Content-Type: application/json" \
"$IMMICH_URL/api/system-config" \
-d "$UPDATED_CONFIG" 2>/dev/null)
[ "$RESULT" = "200" ] \
&& echo " ✓ Storage template configured" \
|| echo " ⚠ Could not set template (HTTP $RESULT) — set manually in Admin → Settings"
else
echo " ⚠ Could not parse config — set storage template manually in Admin → Settings"
fi
else
echo " ⚠ python3 not found — set storage template manually in Admin → Settings"
fi
# ── Install immich-cli if needed ─────────────────────────────────────────────
echo ""
IMMICH_CMD=""
NODE_OK=false
if command -v node &>/dev/null; then
NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1)
[ "$NODE_MAJOR" -ge 20 ] 2>/dev/null && NODE_OK=true
fi
if [ "$NODE_OK" = false ]; then
echo " Immich CLI requires Node.js >= 20 (found: $(node -v 2>/dev/null || echo 'none'))."
read -r -p " Install Node.js 24 LTS now? (y/n): " INSTALL_NODE_YN
if [ "$INSTALL_NODE_YN" = "y" ] || [ "$INSTALL_NODE_YN" = "Y" ]; then
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - 2>/dev/null
sudo apt-get install -y -qq nodejs 2>/dev/null
NODE_MAJOR=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1)
if [ "$NODE_MAJOR" -ge 20 ] 2>/dev/null; then
NODE_OK=true
echo " ✓ Node.js $(node -v) installed"
else
echo " ✗ Installation failed — install Node.js 20+ manually then re-run: $0 $API_KEY"
exit 1
fi
else
echo " Install Node.js 20+ and re-run: $0 $API_KEY"
exit 0
fi
fi
if command -v immich &>/dev/null; then
IMMICH_CMD="immich"
echo " ✓ Immich CLI found"
elif command -v npx &>/dev/null; then
echo " Immich CLI not installed — will use npx."
IMMICH_CMD="npx --yes @immich/cli"
elif command -v npm &>/dev/null; then
echo " Installing Immich CLI globally..."
if npm install -g @immich/cli 2>/dev/null; then
IMMICH_CMD="immich"
echo " ✓ Immich CLI installed"
else
IMMICH_CMD="npx --yes @immich/cli"
fi
fi
[ -z "$IMMICH_CMD" ] && { echo " ✗ No npm/npx found — install manually: npm install -g @immich/cli"; exit 1; }
# ── Run the import ───────────────────────────────────────────────────────────
echo ""
echo " Authenticating CLI..."
$IMMICH_CMD login "$IMMICH_URL/api" "$API_KEY" || { echo " ✗ CLI login failed"; exit 1; }
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Starting import from: $SOURCE_DIR"
echo " Importing ~$PHOTO_COUNT files. This may take a while."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
$IMMICH_CMD upload --recursive "$SOURCE_DIR"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Import complete! View your photos at: $IMMICH_URL"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
IMPORT_BODY
chmod +x "$IMMICH_DIR/import-photos.sh"
chown "$ACTUAL_USER:$ACTUAL_USER" "$IMMICH_DIR/import-photos.sh"
log_success "Import helper written: $IMMICH_DIR/import-photos.sh"
fi
log_success "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} configured at $IMMICH_DIR (port $WEB_PORT)"
configure_caddy_for_service "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${C_SERVER}:2283" "immich${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
# Almost always a no-op on a truly fresh install — the admin account
# (and thus an API key) doesn't exist until the user visits the web UI
# for the first time, which hasn't happened yet at this point in the
# script. Still offered here for the rare case an instance is being
# reconfigured with credentials already in hand; the update rerun path
# above is the realistic way most people complete this.
_immich_offer_authelia_oidc "$IMMICH_DIR" "$WEB_PORT"
write_readme "$IMMICH_DIR" << MD
# Immich${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
Self-hosted photo and video backup — like Google Photos but private.
Mobile apps (iOS/Android) auto-upload in the background.
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
This is a separate, fully isolated instance (own server, own dedicated
database, own port) — not shared photos with another Immich instance.")
- Web UI: http://localhost:${WEB_PORT}
- 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/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
cd $IMMICH_DIR
docker compose up -d # start all containers
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose up -d # update
\`\`\`
## First launch
1. Open http://localhost:${WEB_PORT} and create your admin account.
2. Install the Immich mobile app and point it at \`http://<server-ip>:${WEB_PORT}\`.
3. (External library mode) Go to Admin → External Libraries → Create Library,
set import path to \`/usr/src/app/external\`, and click Scan.
## Import existing photos (unified mode)
\`\`\`bash
./import-photos.sh # interactive
./import-photos.sh <key> # skip login, use existing API key
\`\`\`
## Notes
- Machine learning features (face recognition, CLIP search) require the
\`immich-machine-learning\` container — it pulls a large model on first run.
- The \`.immich\` marker files in the upload subdirs are required by Immich;
do not delete them.
MD
local START_IMMICH=""
prompt_yn "Start Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_IMMICH
if [ "$START_IMMICH" = "y" ] || [ "$START_IMMICH" = "Y" ]; then
docker compose up -d && log_success "Immich${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" || log_warning "Failed to start — check: docker compose logs"
fi
echo ""
echo " Access at: http://localhost:${WEB_PORT}"
echo " First launch: create your admin account in the web UI."
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_immich