이번 글에서는 Proxmox 환경에서 Ubuntu LXC 컨테이너를 생성하고, 그 안에 Docker와 Docker Compose를 설치한 뒤 Immich까지 한 번에 구성하는 과정을 정리해 보았습니다. Immich는 사진과 영상을 직접 보관하고 관리할 수 있는 오픈소스 기반의 개인용 사진 관리 플랫폼인데요 CT ID, CPU와 메모리, 네트워크 방식, 저장경로, 포트번호, 데이터베이스 계정, 도메인 등 주요 설정값은 설치 과정에서 직접 입력할 수 있도록 구성했으며, 사진·영상 저장공간과 PostgreSQL 데이터 경로도 필요에 따라 분리할 수 있습니다. 또한 최신 Immich Compose 파일을 자동으로 불러와 설치하도록 구성해 버전 관리의 번거로움을 줄였고, 설치 후에는 상태 확인, 로그 조회, 재시작, 업데이트, 데이터베이스 백업에 사용할 수 있는 관리 명령어도 함께 제공합니다. Proxmox에서 Immich를 처음 구축하는 사용자도 가능한 한 쉽고 반복 가능하게 설치할 수 있도록 만든 한방 설치 방식입니다.
1. 이 스크립트가 물어보는 것
실행하면 CT ID, 호스트명, Proxmox 저장소, CPU/RAM, DHCP 또는 고정 IP, VLAN, DNS, Immich 포트, 저장 경로, DB 계정 등을 하나씩 물어봅니다.
| 항목 | 기본값 |
|---|---|
| OS | Ubuntu 24.04 LTS |
| LXC | Unprivileged |
| CPU | 4 Core |
| RAM | 8192MB |
| Swap | 2048MB |
| rootfs | 32GB |
| 네트워크 | DHCP |
| Bridge | vmbr0 |
| Immich 포트 | 2283 |
| Compose 경로 | /opt/immich |
| 사진/영상 | /srv/immich/library |
| PostgreSQL | /opt/immich/postgres |
| 시간대 | Asia/Seoul |
| DB 계정 | postgres |
| DB 이름 | immich |
| 미디어 별도 볼륨 | 사용 권장 |
| 도메인 | 선택사항 |
| Caddy HTTPS | 선택사항 |
#!/usr/bin/env bash
# ==============================================================================
# Immich on Proxmox LXC - interactive one-shot installer
# Target: Proxmox VE host (run as root)
# Guest: Ubuntu 24.04 LTS LXC (unprivileged)
#
# What this script does:
# 1) Creates an Ubuntu LXC
# 2) Enables nesting + keyctl for Docker-in-LXC
# 3) Installs Docker Engine + Docker Compose plugin inside the LXC
# 4) Downloads the CURRENT Immich release docker-compose.yml + example.env
# 5) Applies user-selected paths, port, timezone, DB settings
# 6) Optionally creates a separate Proxmox-managed media volume
# 7) Optionally configures Caddy reverse proxy for a domain
# 8) Starts Immich and prints access / management information
#
# Notes:
# - Immich and Proxmox both prefer a VM over Docker-inside-LXC for the most
# supported production deployment. This script intentionally implements LXC
# because that is the requested topology.
# - Hardware acceleration is NOT enabled by default. CPU mode is the safest
# baseline. GPU passthrough should be configured separately per host/GPU.
# ==============================================================================
set -Eeuo pipefail
IFS=$'\n\t'
C_RESET='\033[0m'
C_BOLD='\033[1m'
C_GREEN='\033[32m'
C_YELLOW='\033[33m'
C_RED='\033[31m'
C_CYAN='\033[36m'
info() { echo -e "${C_CYAN}[INFO]${C_RESET} $*"; }
ok() { echo -e "${C_GREEN}[ OK ]${C_RESET} $*"; }
warn() { echo -e "${C_YELLOW}[WARN]${C_RESET} $*"; }
die() { echo -e "${C_RED}[FAIL]${C_RESET} $*" >&2; exit 1; }
title() { echo; echo -e "${C_BOLD}==== $* ====${C_RESET}"; }
on_error() {
local exit_code=$?
local line_no=${1:-?}
echo >&2
echo -e "${C_RED}[ERROR]${C_RESET} line ${line_no}, exit code ${exit_code}" >&2
echo "설치가 중단되었습니다. 위의 오류 메시지를 확인하세요." >&2
exit "$exit_code"
}
trap 'on_error $LINENO' ERR
[[ $EUID -eq 0 ]] || die "Proxmox 호스트의 root 계정으로 실행해야 합니다."
command -v pct >/dev/null 2>&1 || die "pct 명령을 찾을 수 없습니다. Proxmox VE 호스트에서 실행하세요."
command -v pveversion >/dev/null 2>&1 || die "Proxmox VE 환경이 아닙니다."
command -v pveam >/dev/null 2>&1 || die "pveam 명령을 찾을 수 없습니다."
command -v pvesm >/dev/null 2>&1 || die "pvesm 명령을 찾을 수 없습니다."
command -v openssl >/dev/null 2>&1 || die "openssl 명령을 찾을 수 없습니다."
ask() {
local prompt="$1"
local default="${2:-}"
local value
if [[ -n "$default" ]]; then
read -r -p "$prompt [$default]: " value
printf '%s' "${value:-$default}"
else
read -r -p "$prompt: " value
printf '%s' "$value"
fi
}
ask_secret() {
local prompt="$1"
local value
read -r -s -p "$prompt: " value
echo >&2
printf '%s' "$value"
}
ask_yes_no() {
local prompt="$1"
local default="${2:-Y}"
local answer
while true; do
if [[ "$default" =~ ^[Yy]$ ]]; then
read -r -p "$prompt [Y/n]: " answer
answer="${answer:-Y}"
else
read -r -p "$prompt [y/N]: " answer
answer="${answer:-N}"
fi
case "$answer" in
y|Y|yes|YES|Yes) return 0 ;;
n|N|no|NO|No) return 1 ;;
*) echo "y 또는 n으로 입력하세요." ;;
esac
done
}
validate_int() {
[[ "$1" =~ ^[0-9]+$ ]] || die "$2 값은 숫자여야 합니다: $1"
}
validate_port() {
validate_int "$1" "포트"
(( $1 >= 1 && $1 <= 65535 )) || die "포트는 1~65535 범위여야 합니다."
}
validate_abs_path() {
[[ "$1" == /* ]] || die "$2 경로는 / 로 시작하는 절대경로여야 합니다: $1"
[[ "$1" =~ ^/[A-Za-z0-9_./-]+$ ]] || die "$2 경로에는 영문/숫자/./_/- 만 사용하세요: $1"
}
validate_pg_ident() {
[[ "$1" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || die "$2에는 영문/숫자/_ 만 사용할 수 있고 첫 글자는 숫자일 수 없습니다."
}
storage_exists() {
pvesm status 2>/dev/null | awk 'NR>1 {print $1}' | grep -Fxq "$1"
}
bridge_exists() {
ip link show "$1" >/dev/null 2>&1
}
storage_supports_content() {
local storage="$1"
local content="$2"
pvesm config "$storage" 2>/dev/null | awk -F': ' '$1=="content" {print $2}' \
| tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | grep -Fxq "$content"
}
get_first_storage_for_content() {
local content="$1"
# "pvesm status --content" is not identical across all PVE releases,
# so query storage config and prefer active storages where possible.
local s
for s in $(pvesm status 2>/dev/null | awk 'NR>1 && $3=="active" {print $1}'); do
if pvesm config "$s" 2>/dev/null | grep -Eq "^content:.*(^|,| )${content}(,|$| )"; then
echo "$s"
return 0
fi
done
for s in $(pvesm status 2>/dev/null | awk 'NR>1 {print $1}'); do
if pvesm config "$s" 2>/dev/null | grep -Eq "^content:.*(^|,| )${content}(,|$| )"; then
echo "$s"
return 0
fi
done
return 1
}
print_storage_table() {
echo
pvesm status || true
echo
}
title "1. 기본 환경 확인"
PVE_VER="$(pveversion | head -n1)"
info "감지된 Proxmox: $PVE_VER"
ARCH="$(dpkg --print-architecture 2>/dev/null || uname -m)"
case "$ARCH" in
amd64|x86_64) ;;
*) warn "현재 호스트 아키텍처가 $ARCH 입니다. 이 스크립트는 amd64 Proxmox에서 가장 많이 검증된 형태입니다." ;;
esac
DEFAULT_CTID="$(pvesh get /cluster/nextid 2>/dev/null || true)"
[[ -n "$DEFAULT_CTID" ]] || DEFAULT_CTID="200"
DEFAULT_TEMPLATE_STORAGE="$(get_first_storage_for_content vztmpl || true)"
[[ -n "$DEFAULT_TEMPLATE_STORAGE" ]] || DEFAULT_TEMPLATE_STORAGE="local"
DEFAULT_ROOTFS_STORAGE="$(get_first_storage_for_content rootdir || true)"
[[ -n "$DEFAULT_ROOTFS_STORAGE" ]] || DEFAULT_ROOTFS_STORAGE="local-lvm"
title "2. LXC 기본 정보"
CTID="$(ask "LXC CT ID" "$DEFAULT_CTID")"
validate_int "$CTID" "CT ID"
pct status "$CTID" >/dev/null 2>&1 && die "CT ID $CTID 는 이미 사용 중입니다."
CT_HOSTNAME="$(ask "LXC 호스트명" "immich")"
[[ "$CT_HOSTNAME" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]] || die "호스트명 형식이 올바르지 않습니다."
TEMPLATE_STORAGE="$(ask "Ubuntu 템플릿 저장소 ID" "$DEFAULT_TEMPLATE_STORAGE")"
storage_exists "$TEMPLATE_STORAGE" || { print_storage_table; die "저장소 '$TEMPLATE_STORAGE' 를 찾을 수 없습니다."; }
storage_supports_content "$TEMPLATE_STORAGE" vztmpl || { print_storage_table; die "저장소 '$TEMPLATE_STORAGE' 는 LXC 템플릿(vztmpl) 콘텐츠를 지원하지 않습니다."; }
ROOTFS_STORAGE="$(ask "LXC rootfs 저장소 ID" "$DEFAULT_ROOTFS_STORAGE")"
storage_exists "$ROOTFS_STORAGE" || { print_storage_table; die "저장소 '$ROOTFS_STORAGE' 를 찾을 수 없습니다."; }
storage_supports_content "$ROOTFS_STORAGE" rootdir || { print_storage_table; die "저장소 '$ROOTFS_STORAGE' 는 LXC rootdir 콘텐츠를 지원하지 않습니다."; }
ROOTFS_SIZE="$(ask "rootfs 크기(GB)" "32")"
CORES="$(ask "CPU 코어 수" "4")"
MEMORY="$(ask "메모리(MB)" "8192")"
SWAP="$(ask "Swap(MB)" "2048")"
validate_int "$ROOTFS_SIZE" "rootfs 크기"
validate_int "$CORES" "CPU 코어"
validate_int "$MEMORY" "메모리"
validate_int "$SWAP" "Swap"
if (( MEMORY < 6144 )); then
warn "Immich 공식 최소 RAM은 6GB입니다. 현재 ${MEMORY}MB로 설정했습니다."
fi
if (( CORES < 2 )); then
warn "Immich 공식 최소 CPU는 2코어입니다."
fi
title "3. 네트워크"
BRIDGE="$(ask "Proxmox 브리지" "vmbr0")"
bridge_exists "$BRIDGE" || warn "브리지 '$BRIDGE' 를 ip link에서 찾지 못했습니다. 이름을 다시 확인하세요."
echo " 1) DHCP (가장 간단)"
echo " 2) 고정 IP"
NET_MODE="$(ask "네트워크 방식" "1")"
case "$NET_MODE" in
1)
NET0="name=eth0,bridge=${BRIDGE},ip=dhcp,type=veth"
;;
2)
STATIC_IP="$(ask "LXC IPv4/CIDR 예: 192.168.0.50/24" "")"
[[ "$STATIC_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/[0-9]+$ ]] || die "IPv4/CIDR 형식을 확인하세요."
GATEWAY="$(ask "게이트웨이 예: 192.168.0.1" "")"
[[ "$GATEWAY" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "게이트웨이 형식을 확인하세요."
NET0="name=eth0,bridge=${BRIDGE},ip=${STATIC_IP},gw=${GATEWAY},type=veth"
;;
*)
die "네트워크 방식은 1 또는 2를 선택하세요."
;;
esac
VLAN_TAG="$(ask "VLAN ID (사용하지 않으면 비움)" "")"
if [[ -n "$VLAN_TAG" ]]; then
validate_int "$VLAN_TAG" "VLAN ID"
(( VLAN_TAG >= 1 && VLAN_TAG <= 4094 )) || die "VLAN ID는 1~4094 범위여야 합니다."
NET0="${NET0},tag=${VLAN_TAG}"
fi
DNS_SERVER="$(ask "DNS 서버 (비우면 Proxmox 기본값 사용)" "")"
title "4. Immich 기본 정보"
TIMEZONE="$(ask "시간대(TZ)" "Asia/Seoul")"
HOST_PORT="$(ask "Immich 접속 포트" "2283")"
validate_port "$HOST_PORT"
APP_DIR="$(ask "LXC 내부 Immich 설정/Compose 경로" "/opt/immich")"
UPLOAD_DIR="$(ask "LXC 내부 사진/영상 저장 경로" "/srv/immich/library")"
DB_DIR="$(ask "LXC 내부 PostgreSQL 데이터 경로" "/opt/immich/postgres")"
validate_abs_path "$APP_DIR" "Immich 설정"
validate_abs_path "$UPLOAD_DIR" "미디어"
validate_abs_path "$DB_DIR" "DB"
if [[ "$APP_DIR" == "$UPLOAD_DIR" || "$APP_DIR" == "$DB_DIR" || "$UPLOAD_DIR" == "$DB_DIR" ]]; then
die "APP/UPLOAD/DB 경로는 서로 다른 경로를 사용하세요."
fi
echo
echo "미디어(사진/영상)는 대용량이 되므로 rootfs와 분리하는 것을 권장합니다."
if ask_yes_no "미디어를 별도 Proxmox 관리 볼륨(mp0)으로 만들까요?" "Y"; then
MEDIA_SEPARATE=1
DEFAULT_MEDIA_STORAGE="$ROOTFS_STORAGE"
MEDIA_STORAGE="$(ask "미디어 볼륨 저장소 ID" "$DEFAULT_MEDIA_STORAGE")"
storage_exists "$MEDIA_STORAGE" || { print_storage_table; die "저장소 '$MEDIA_STORAGE' 를 찾을 수 없습니다."; }
storage_supports_content "$MEDIA_STORAGE" rootdir || { print_storage_table; die "저장소 '$MEDIA_STORAGE' 는 LXC mount point(rootdir)를 지원하지 않습니다."; }
MEDIA_SIZE_GB="$(ask "미디어 볼륨 크기(GB)" "500")"
validate_int "$MEDIA_SIZE_GB" "미디어 볼륨 크기"
if ask_yes_no "Proxmox vzdump 백업에 이 미디어 볼륨도 포함할까요? (대용량이면 보통 N)" "N"; then
MEDIA_BACKUP=1
else
MEDIA_BACKUP=0
fi
else
MEDIA_SEPARATE=0
warn "미디어가 rootfs에 저장됩니다. rootfs ${ROOTFS_SIZE}GB가 충분한지 반드시 확인하세요."
fi
echo
echo "PostgreSQL 저장장치 종류:"
echo " 1) SSD/NVMe (권장)"
echo " 2) HDD"
DB_STORAGE_CHOICE="$(ask "DB 저장장치 종류" "1")"
case "$DB_STORAGE_CHOICE" in
1) DB_STORAGE_TYPE="SSD" ;;
2) DB_STORAGE_TYPE="HDD" ;;
*) die "1 또는 2를 선택하세요." ;;
esac
DB_USERNAME="$(ask "PostgreSQL 계정명" "postgres")"
DB_DATABASE_NAME="$(ask "PostgreSQL DB 이름" "immich")"
validate_pg_ident "$DB_USERNAME" "DB 계정명"
validate_pg_ident "$DB_DATABASE_NAME" "DB 이름"
echo
echo "DB 비밀번호는 Immich 권장사항에 맞춰 영문/숫자만 사용합니다."
DB_PASSWORD="$(ask_secret "DB 비밀번호 (비우면 안전한 임의값 자동 생성)")"
if [[ -z "$DB_PASSWORD" ]]; then
DB_PASSWORD="$(openssl rand -hex 20)"
ok "DB 비밀번호를 자동 생성했습니다."
fi
[[ "$DB_PASSWORD" =~ ^[A-Za-z0-9]+$ ]] || die "DB 비밀번호는 영문/숫자만 사용하세요."
IMMICH_VERSION="$(ask "Immich 버전 (비우면 공식 example.env의 기본값 유지)" "")"
DOMAIN="$(ask "Immich 도메인 (예: photos.example.com, 없으면 비움)" "")"
ENABLE_CADDY=0
if [[ -n "$DOMAIN" ]]; then
echo
echo "도메인을 입력했습니다. Caddy 자동 HTTPS를 사용할 수 있습니다."
echo "조건: 도메인의 DNS가 이 LXC로 연결되고, 외부 80/443 포트가 LXC까지 도달해야 합니다."
echo "Cloudflare Tunnel/Nginx Proxy Manager를 따로 쓸 계획이면 여기서는 N을 선택하세요."
if ask_yes_no "이 LXC에 Caddy reverse proxy + 자동 HTTPS를 설치할까요?" "N"; then
ENABLE_CADDY=1
fi
fi
title "5. LXC root 비밀번호"
ROOT_PASSWORD="$(ask_secret "LXC root 비밀번호 (비우면 임의값 자동 생성)")"
if [[ -z "$ROOT_PASSWORD" ]]; then
ROOT_PASSWORD="$(openssl rand -hex 12)"
ok "LXC root 비밀번호를 자동 생성했습니다."
fi
echo
title "설치 설정 요약"
cat <<EOF
CT ID : $CTID
Hostname : $CT_HOSTNAME
Template storage : $TEMPLATE_STORAGE
Rootfs storage : $ROOTFS_STORAGE
Rootfs : ${ROOTFS_SIZE} GB
CPU / RAM / Swap : ${CORES} core / ${MEMORY} MB / ${SWAP} MB
Network : $NET0
Timezone : $TIMEZONE
Immich port : $HOST_PORT
App dir : $APP_DIR
Upload dir : $UPLOAD_DIR
DB dir : $DB_DIR
DB storage type : $DB_STORAGE_TYPE
Separate media : $([[ "$MEDIA_SEPARATE" -eq 1 ]] && echo "YES (${MEDIA_STORAGE}:${MEDIA_SIZE_GB}GB, backup=${MEDIA_BACKUP})" || echo "NO")
Domain : ${DOMAIN:-"(none)"}
Caddy HTTPS : $([[ "$ENABLE_CADDY" -eq 1 ]] && echo YES || echo NO)
LXC security : unprivileged + nesting=1,keyctl=1
GPU acceleration : CPU mode (not configured)
EOF
echo
if ! ask_yes_no "위 설정으로 설치를 시작할까요?" "Y"; then
echo "취소했습니다."
exit 0
fi
title "6. Ubuntu 24.04 LXC 템플릿 준비"
pveam update >/dev/null
TEMPLATE_NAME="$(pveam available --section system 2>/dev/null \
| awk '$2 ~ /^ubuntu-24\.04-standard_.*_amd64\.tar\.(zst|gz)$/ {print $2}' \
| sort -V | tail -n1)"
if [[ -z "$TEMPLATE_NAME" ]]; then
# Some pveam output versions do not split exactly the same way; use a looser fallback.
TEMPLATE_NAME="$(pveam available --section system 2>/dev/null \
| grep -oE 'ubuntu-24\.04-standard_[^ ]+_amd64\.tar\.(zst|gz)' \
| sort -V | tail -n1 || true)"
fi
[[ -n "$TEMPLATE_NAME" ]] || die "Ubuntu 24.04 amd64 LXC 템플릿을 pveam에서 찾지 못했습니다."
if pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -Fq "$TEMPLATE_NAME"; then
ok "템플릿이 이미 존재합니다: $TEMPLATE_NAME"
else
info "템플릿 다운로드: $TEMPLATE_NAME"
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE_NAME"
fi
TEMPLATE_REF="${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE_NAME}"
title "7. LXC 생성"
CREATE_ARGS=(
"$CTID" "$TEMPLATE_REF"
--hostname "$CT_HOSTNAME"
--ostype ubuntu
--cores "$CORES"
--memory "$MEMORY"
--swap "$SWAP"
--rootfs "${ROOTFS_STORAGE}:${ROOTFS_SIZE}"
--unprivileged 1
--features "nesting=1,keyctl=1"
--net0 "$NET0"
--onboot 1
--password "$ROOT_PASSWORD"
)
if [[ -n "$DNS_SERVER" ]]; then
CREATE_ARGS+=(--nameserver "$DNS_SERVER")
fi
pct create "${CREATE_ARGS[@]}"
if [[ "$MEDIA_SEPARATE" -eq 1 ]]; then
info "미디어 볼륨 생성 및 mp0 연결"
pct set "$CTID" --mp0 "${MEDIA_STORAGE}:${MEDIA_SIZE_GB},mp=${UPLOAD_DIR},backup=${MEDIA_BACKUP}"
fi
ok "LXC 생성 완료"
title "8. LXC 시작 및 네트워크 대기"
pct start "$CTID"
for i in {1..60}; do
if pct exec "$CTID" -- bash -lc 'systemctl is-system-running --wait >/dev/null 2>&1 || true; ip -4 addr show scope global | grep -q "inet "' >/dev/null 2>&1; then
break
fi
sleep 2
done
for i in {1..60}; do
if pct exec "$CTID" -- bash -lc 'getent hosts download.docker.com >/dev/null 2>&1 && getent hosts github.com >/dev/null 2>&1'; then
break
fi
if (( i == 60 )); then
die "LXC에서 인터넷/DNS 연결이 확인되지 않습니다. 네트워크 설정을 점검하세요."
fi
sleep 2
done
ok "LXC 네트워크 정상"
title "9. LXC 내부 설치 스크립트 준비"
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
CONF_FILE="$TMPDIR/immich-install.conf"
INNER_SCRIPT="$TMPDIR/install-immich-inside.sh"
{
printf 'APP_DIR=%q\n' "$APP_DIR"
printf 'UPLOAD_DIR=%q\n' "$UPLOAD_DIR"
printf 'DB_DIR=%q\n' "$DB_DIR"
printf 'TIMEZONE=%q\n' "$TIMEZONE"
printf 'HOST_PORT=%q\n' "$HOST_PORT"
printf 'DB_USERNAME=%q\n' "$DB_USERNAME"
printf 'DB_PASSWORD=%q\n' "$DB_PASSWORD"
printf 'DB_DATABASE_NAME=%q\n' "$DB_DATABASE_NAME"
printf 'DB_STORAGE_TYPE=%q\n' "$DB_STORAGE_TYPE"
printf 'IMMICH_VERSION=%q\n' "$IMMICH_VERSION"
printf 'DOMAIN=%q\n' "$DOMAIN"
printf 'ENABLE_CADDY=%q\n' "$ENABLE_CADDY"
} > "$CONF_FILE"
chmod 600 "$CONF_FILE"
cat > "$INNER_SCRIPT" <<'INNER_EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
source /root/immich-install.conf
export DEBIAN_FRONTEND=noninteractive
log() { echo "[LXC] $*"; }
log "기본 패키지 설치"
apt-get update
apt-get install -y ca-certificates curl wget gnupg openssl tzdata nano jq python3
ln -snf "/usr/share/zoneinfo/${TIMEZONE}" /etc/localtime
echo "$TIMEZONE" > /etc/timezone
log "Docker 공식 저장소 구성"
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
. /etc/os-release
CODENAME="${UBUNTU_CODENAME:-${VERSION_CODENAME}}"
ARCH="$(dpkg --print-architecture)"
echo "deb [arch=${ARCH} signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu ${CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
log "Docker 동작 확인"
docker version
docker compose version
docker run --rm hello-world >/dev/null
log "Docker 컨테이너 실행 테스트 정상"
mkdir -p "$APP_DIR" "$UPLOAD_DIR" "$DB_DIR"
chmod 755 "$APP_DIR" "$UPLOAD_DIR" "$DB_DIR"
cd "$APP_DIR"
if [[ -f docker-compose.yml ]]; then
cp -a docker-compose.yml "docker-compose.yml.bak.$(date +%Y%m%d-%H%M%S)"
fi
if [[ -f .env ]]; then
cp -a .env ".env.bak.$(date +%Y%m%d-%H%M%S)"
fi
log "Immich 현재 릴리스 Compose 및 example.env 다운로드"
curl -fL --retry 3 --connect-timeout 15 \
https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml \
-o docker-compose.yml
curl -fL --retry 3 --connect-timeout 15 \
https://github.com/immich-app/immich/releases/latest/download/example.env \
-o .env
set_env() {
local key="$1"
local value="$2"
local escaped
escaped="$(printf '%s' "$value" | sed -e 's/[\/&|]/\\&/g')"
if grep -qE "^${key}=" .env; then
sed -i -E "s|^${key}=.*$|${key}=${escaped}|" .env
elif grep -qE "^#[[:space:]]*${key}=" .env; then
sed -i -E "s|^#[[:space:]]*${key}=.*$|${key}=${escaped}|" .env
else
printf '\n%s=%s\n' "$key" "$value" >> .env
fi
}
set_env "UPLOAD_LOCATION" "$UPLOAD_DIR"
set_env "DB_DATA_LOCATION" "$DB_DIR"
set_env "TZ" "$TIMEZONE"
set_env "DB_PASSWORD" "$DB_PASSWORD"
set_env "DB_USERNAME" "$DB_USERNAME"
set_env "DB_DATABASE_NAME" "$DB_DATABASE_NAME"
set_env "DB_STORAGE_TYPE" "$DB_STORAGE_TYPE"
if [[ -n "$IMMICH_VERSION" ]]; then
set_env "IMMICH_VERSION" "$IMMICH_VERSION"
fi
chmod 600 .env
log "Immich 외부 포트 ${HOST_PORT} 적용"
python3 - "$HOST_PORT" <<'PY_EOF'
import re
import sys
from pathlib import Path
port = sys.argv[1]
path = Path("docker-compose.yml")
text = path.read_text()
# Restrict replacement to the immich-server service, then its first port mapping.
m = re.search(
r"(?ms)(^ immich-server:\s*\n)(.*?)(?=^ [A-Za-z0-9_.-]+:\s*$|\Z)",
text,
)
if not m:
raise SystemExit("immich-server service block not found in docker-compose.yml")
block = m.group(0)
new_block, count = re.subn(
r"(?m)^(\s{4}ports:\s*\n)\s{6}-\s*['\"]?[^'\"]+:2283['\"]?\s*$",
lambda x: x.group(1) + f" - '{port}:2283'",
block,
count=1,
)
if count != 1:
# Current official file normally exposes 2283:2283. If the format changes,
# fail instead of silently starting on the wrong port.
raise SystemExit("Could not safely patch immich-server port mapping")
text = text[:m.start()] + new_block + text[m.end():]
path.write_text(text)
PY_EOF
log "Compose 문법 검사"
docker compose config >/dev/null
log "Immich 이미지 다운로드 및 시작"
docker compose pull
docker compose up -d
# Useful helper commands
cat > /usr/local/sbin/immich-update <<EOF
#!/usr/bin/env bash
set -e
cd "$APP_DIR"
docker compose pull
docker compose up -d
docker image prune -f
EOF
cat > /usr/local/sbin/immich-status <<EOF
#!/usr/bin/env bash
cd "$APP_DIR"
docker compose ps
EOF
cat > /usr/local/sbin/immich-logs <<EOF
#!/usr/bin/env bash
cd "$APP_DIR"
docker compose logs -f --tail=150
EOF
cat > /usr/local/sbin/immich-restart <<EOF
#!/usr/bin/env bash
set -e
cd "$APP_DIR"
docker compose restart
docker compose ps
EOF
cat > /usr/local/sbin/immich-db-backup <<EOF
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="$APP_DIR/backups"
mkdir -p "\$BACKUP_DIR"
STAMP=\$(date +%Y%m%d-%H%M%S)
docker exec immich_postgres pg_dumpall -U "$DB_USERNAME" \
| gzip > "\$BACKUP_DIR/immich-postgres-\${STAMP}.sql.gz"
echo "Saved: \$BACKUP_DIR/immich-postgres-\${STAMP}.sql.gz"
EOF
chmod 755 /usr/local/sbin/immich-update \
/usr/local/sbin/immich-status \
/usr/local/sbin/immich-logs \
/usr/local/sbin/immich-restart \
/usr/local/sbin/immich-db-backup
if [[ "$ENABLE_CADDY" == "1" && -n "$DOMAIN" ]]; then
log "Caddy reverse proxy 설치"
apt-get install -y caddy
cat > /etc/caddy/Caddyfile <<EOF
$DOMAIN {
encode zstd gzip
reverse_proxy 127.0.0.1:$HOST_PORT
}
EOF
caddy validate --config /etc/caddy/Caddyfile
systemctl enable --now caddy
systemctl restart caddy
fi
cat > /root/IMMICH-README.txt <<EOF
Immich installation
===================
App directory : $APP_DIR
Media path : $UPLOAD_DIR
Database path : $DB_DIR
Web port : $HOST_PORT
Timezone : $TIMEZONE
Domain : ${DOMAIN:-none}
Common commands
---------------
immich-status
immich-logs
immich-restart
immich-update
immich-db-backup
Manual commands
---------------
cd $APP_DIR
docker compose ps
docker compose logs -f
docker compose pull
docker compose up -d
Important
---------
1. The first Immich ADMIN user is created in the Immich Web UI.
2. Keep .env private because it contains the database password.
3. Before updating Immich, read the release notes for breaking changes.
4. The database directory should not be placed on a network share.
5. Media and database must be backed up separately according to your backup policy.
EOF
chmod 600 /root/IMMICH-README.txt
log "컨테이너 상태"
docker compose ps
INNER_EOF
chmod 700 "$INNER_SCRIPT"
pct push "$CTID" "$CONF_FILE" /root/immich-install.conf -perms 600
pct push "$CTID" "$INNER_SCRIPT" /root/install-immich-inside.sh -perms 700
title "10. Docker + Immich 설치"
pct exec "$CTID" -- bash /root/install-immich-inside.sh
title "11. 최종 상태 확인"
LXC_IP="$(pct exec "$CTID" -- bash -lc "hostname -I | awk '{print \$1}'" 2>/dev/null | tr -d '\r' || true)"
[[ -n "$LXC_IP" ]] || LXC_IP="<LXC-IP>"
# Wait briefly for service health/startup
pct exec "$CTID" -- bash -lc "cd '$APP_DIR' && docker compose ps" || true
SUMMARY_HOST="/root/immich-lxc-${CTID}-summary.txt"
umask 077
cat > "$SUMMARY_HOST" <<EOF
Immich LXC installation summary
===============================
Created : $(date -Is)
CT ID : $CTID
Hostname : $CT_HOSTNAME
LXC IP : $LXC_IP
Web URL : http://${LXC_IP}:${HOST_PORT}
Domain : ${DOMAIN:-none}
HTTPS URL : $([[ "$ENABLE_CADDY" -eq 1 && -n "$DOMAIN" ]] && echo "https://${DOMAIN}" || echo "not configured by this script")
App dir : $APP_DIR
Media dir : $UPLOAD_DIR
Database dir : $DB_DIR
DB user : $DB_USERNAME
DB name : $DB_DATABASE_NAME
DB password : $DB_PASSWORD
Root password : $ROOT_PASSWORD
Media volume : $([[ "$MEDIA_SEPARATE" -eq 1 ]] && echo "${MEDIA_STORAGE}:${MEDIA_SIZE_GB}GB -> ${UPLOAD_DIR}, backup=${MEDIA_BACKUP}" || echo "inside rootfs")
Enter LXC:
pct enter $CTID
Inside LXC:
immich-status
immich-logs
immich-update
immich-restart
immich-db-backup
Compose directory:
$APP_DIR
First admin account:
Open Immich Web UI and create the first administrator account there.
EOF
echo
ok "설치 완료"
echo
echo -e "${C_BOLD}접속 주소:${C_RESET} http://${LXC_IP}:${HOST_PORT}"
if [[ "$ENABLE_CADDY" -eq 1 && -n "$DOMAIN" ]]; then
echo -e "${C_BOLD}도메인:${C_RESET} https://${DOMAIN}"
elif [[ -n "$DOMAIN" ]]; then
echo -e "${C_BOLD}입력한 도메인:${C_RESET} $DOMAIN (reverse proxy는 별도 구성)"
fi
echo
echo "Proxmox에서 LXC 진입:"
echo " pct enter $CTID"
echo
echo "LXC 안에서 상태 확인:"
echo " immich-status"
echo
echo "로그:"
echo " immich-logs"
echo
echo "업데이트:"
echo " immich-update"
echo
echo "DB 수동 백업:"
echo " immich-db-backup"
echo
echo "설치 정보(비밀번호 포함, root만 읽기 가능):"
echo " $SUMMARY_HOST"
echo
warn "첫 Immich 관리자 계정은 웹 화면에서 직접 생성합니다. Compose/.env에 웹 관리자 계정을 미리 넣는 방식은 사용하지 않습니다."
warn "GPU 가속은 이 기본 설치에 포함하지 않았습니다. Intel/AMD /dev/dri 또는 NVIDIA GPU 종류에 맞춰 별도로 구성하는 것이 안전합니다."
chmod +x /root/install_immich_proxmox_lxc.sh
bash /root/install_immich_proxmox_lxc.sh