diff --git a/immich_backup.md b/immich_backup.md new file mode 100644 index 0000000..b7f6a83 --- /dev/null +++ b/immich_backup.md @@ -0,0 +1,230 @@ +--- +title: Immich Backup and Restore +description: Immich backup with Kopia +published: true +date: 2026-02-14T03:14:32.594Z +tags: +editor: markdown +dateCreated: 2026-02-14T03:14:32.594Z +--- + +#!/bin/bash + +# Immich Backup Script +# Two-tier approach matching mailcow backup strategy: +# Tier 1 (Local): Component-level backups (database + volumes) +# Tier 2 (Offsite): Kopia snapshots to vaults + +set -euo pipefail + +# Configuration +BACKUP_DIR="/opt/immich-backups" +RETENTION_DAYS=7 # Keep local backups for 7 days (like mailcow) +BACKUP_DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_PATH="${BACKUP_DIR}/immich-${BACKUP_DATE}" +LOG_FILE="/var/log/immich-backup.log" + +# Immich configuration (adjust these to your setup) +IMMICH_DIR="/opt/immich" # or wherever your docker-compose.yml is +COMPOSE_FILE="${IMMICH_DIR}/docker-compose.yml" +POSTGRES_CONTAINER="immich_postgres" # adjust if different +PROJECT_NAME="immich" # Docker Compose project name + +# Kopia configuration +ENABLE_KOPIA=true # Set to false to disable offsite backups +KOPIA_TAGS="immich,tier1-backup" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log() { + echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE" +} + +error() { + echo -e "${RED}[$(date +'%Y-%m-%d %H:%M:%S')] ERROR:${NC} $1" | tee -a "$LOG_FILE" +} + +warn() { + echo -e "${YELLOW}[$(date +'%Y-%m-%d %H:%M:%S')] WARNING:${NC} $1" | tee -a "$LOG_FILE" +} + +# Create backup directory +mkdir -p "${BACKUP_PATH}" + +log "========================================" +log "Starting Immich Tier 1 (Local) backup" +log "Backup path: ${BACKUP_PATH}" +log "========================================" + +# 1. Backup PostgreSQL database +log "Backing up PostgreSQL database..." +docker exec -t "${POSTGRES_CONTAINER}" pg_dumpall -c -U postgres | gzip > "${BACKUP_PATH}/database.sql.gz" + +if [ $? -eq 0 ]; then + DB_SIZE=$(du -h "${BACKUP_PATH}/database.sql.gz" | cut -f1) + log "Database backup completed successfully (${DB_SIZE})" +else + error "Database backup failed" + exit 1 +fi + +# 2. Backup Immich volumes/data +log "Backing up Immich data volumes..." + +# Get actual Docker volumes for this project +DOCKER_VOLUMES=$(docker volume ls --filter "name=${PROJECT_NAME}" --format "{{.Name}}") + +if [ -z "${DOCKER_VOLUMES}" ]; then + warn "No Docker volumes found with name pattern '${PROJECT_NAME}'. Checking for bind mounts..." + + # Fallback to bind mounts if present + VOLUMES=( + "upload" + "library" + "profile" + "video" + "thumbs" + "encoded-video" + ) + + for volume in "${VOLUMES[@]}"; do + SOURCE_PATH="${IMMICH_DIR}/${volume}" + if [ -d "${SOURCE_PATH}" ]; then + log "Backing up bind mount: ${volume}..." + tar -czf "${BACKUP_PATH}/${volume}.tar.gz" -C "${IMMICH_DIR}" "${volume}" 2>/dev/null || warn "${volume} backup failed" + fi + done +else + # Backup Docker volumes + for vol in ${DOCKER_VOLUMES}; do + log "Backing up Docker volume: ${vol}" + docker run --rm \ + -v "${vol}:/data:ro" \ + -v "${BACKUP_PATH}:/backup" \ + alpine tar -czf "/backup/${vol}.tar.gz" -C /data . || warn "Failed to backup ${vol}" + + if [ -f "${BACKUP_PATH}/${vol}.tar.gz" ]; then + VOL_SIZE=$(du -h "${BACKUP_PATH}/${vol}.tar.gz" | cut -f1) + log " ✓ ${vol} backed up (${VOL_SIZE})" + fi + done +fi + +# 3. Backup configuration files +log "Backing up configuration files..." +if [ -f "${COMPOSE_FILE}" ]; then + cp "${COMPOSE_FILE}" "${BACKUP_PATH}/docker-compose.yml" + log " ✓ docker-compose.yml" +fi + +if [ -f "${IMMICH_DIR}/.env" ]; then + cp "${IMMICH_DIR}/.env" "${BACKUP_PATH}/.env" + log " ✓ .env" +fi + +# Backup any custom config files +if [ -d "${IMMICH_DIR}/config" ]; then + tar -czf "${BACKUP_PATH}/config.tar.gz" -C "${IMMICH_DIR}" config 2>/dev/null && log " ✓ config directory" || true +fi + +# 4. Create backup manifest (matching mailcow style) +log "Creating backup manifest..." +cat > "${BACKUP_PATH}/manifest.txt" << EOF +Immich Backup Manifest +Created: ${BACKUP_DATE} +Hostname: $(hostname) +Immich Directory: ${IMMICH_DIR} +Project Name: ${PROJECT_NAME} + +Backup Contents: +$(ls -lh "${BACKUP_PATH}" 2>/dev/null) + +Component Sizes: +Database: $(du -h "${BACKUP_PATH}/database.sql.gz" 2>/dev/null | cut -f1 || echo "N/A") +Total Backup Size: $(du -sh "${BACKUP_PATH}" 2>/dev/null | cut -f1) + +Docker Volumes Backed Up: +${DOCKER_VOLUMES:-None (using bind mounts)} +EOF + +# 5. Calculate checksums +log "Calculating checksums..." +cd "${BACKUP_PATH}" +find . -type f -name "*.tar.gz" -o -name "*.sql.gz" | xargs sha256sum > checksums.sha256 2>/dev/null || warn "Checksum creation failed" +cd - > /dev/null + +TIER1_SIZE=$(du -sh "${BACKUP_PATH}" | cut -f1) +log "Tier 1 (Local) backup completed! Size: ${TIER1_SIZE}" + +# 6. Tier 2: Create Kopia snapshot for offsite backup +if [ "$ENABLE_KOPIA" = true ]; then + log "========================================" + log "Starting Tier 2 (Offsite) Kopia backup" + log "========================================" + + # Check if kopia is available + if ! command -v kopia &> /dev/null; then + warn "Kopia not found. Skipping offsite backup." + warn "Install kopia or set ENABLE_KOPIA=false" + else + # Snapshot the backup directory + log "Creating Kopia snapshot of backup directory..." + kopia snapshot create "${BACKUP_DIR}" \ + --tags "${KOPIA_TAGS}" \ + --description "Immich backup ${BACKUP_DATE}" \ + 2>&1 | tee -a "$LOG_FILE" + + KOPIA_EXIT=${PIPESTATUS[0]} + + if [ $KOPIA_EXIT -eq 0 ]; then + log "Kopia snapshot completed successfully" + + # Also snapshot the Immich installation directory (configs) + log "Creating Kopia snapshot of installation directory..." + kopia snapshot create "${IMMICH_DIR}" \ + --tags "immich,config,docker-compose" \ + --description "Immich config ${BACKUP_DATE}" \ + 2>&1 | tee -a "$LOG_FILE" + else + warn "Kopia snapshot failed with exit code ${KOPIA_EXIT}" + warn "Local backup exists but offsite copy may be incomplete" + fi + fi +fi + +# 7. Cleanup old backups (retention policy) +log "========================================" +log "Applying retention policy (${RETENTION_DAYS} days)" +log "========================================" + +# Find and remove old local backups +REMOVED_COUNT=0 +while IFS= read -r old_backup; do + log "Removing old backup: $(basename "$old_backup")" + rm -rf "$old_backup" + ((REMOVED_COUNT++)) +done < <(find "${BACKUP_DIR}" -maxdepth 1 -type d -name "immich-*" -mtime +${RETENTION_DAYS} 2>/dev/null) + +if [ $REMOVED_COUNT -gt 0 ]; then + log "Removed ${REMOVED_COUNT} old backup(s)" +else + log "No old backups to remove" +fi + +# 8. Final summary +log "========================================" +log "Backup Summary" +log "========================================" +log "Tier 1 (Local): ${TIER1_SIZE} in ${BACKUP_PATH}" +log "Tier 2 (Offsite): Kopia snapshot created" +log "Retention: Keeping last ${RETENTION_DAYS} days locally" +log "Log file: ${LOG_FILE}" +log "========================================" +log "Backup completed successfully!" +log "========================================" + +exit 0 \ No newline at end of file