Engineering

把 Immich 放在家里,把公网入口放在 OCI

一套保留家庭数据、无需手机 VPN、通过公网 HTTPS 访问 Immich 的部署记录与复现教程

  • Immich
  • Docker
  • Tailscale
  • Caddy
  • 私有云
  • 家庭照片

我想要的是一套家人能直接使用的照片云:

  • 原照片放在家里的服务器上。
  • 手机通过公网 HTTPS 访问。
  • 手机端不安装 Tailscale、WireGuard 或其他 VPN。
  • OCI 只做公网入口,不承担照片主存储。
  • 不购买域名,也不重装已有服务器。
  • 现有网站、代理、OCR 和其他服务继续运行。

这篇文章记录最终跑通的方案,以及部署过程中真正有用的验证方法。文中的公网域名、IP、Tailscale 地址、用户名、服务器路径和密码全部使用占位符,发布前不要替换成真实值。

这篇文章描述的是一套已经验证过的部署路径。版本号、镜像标签和官方安装文件会随 Immich 发布变化,实际操作时仍应以对应 Stable release 的官方文件为准。

先看架构

数据服务器和公网网关分开:

家人手机
   │ HTTPS
   ▼
OCI 公网网关
   │ Caddy + Let's Encrypt
   │ Tailscale 私网链路
   ▼
家中数据服务器
   │
   ├── Immich Server
   ├── PostgreSQL
   ├── Valkey/Redis
   ├── Machine Learning
   ├── 托管照片和视频
   └── DJI External Library

这里有一个部署约束:OCI 的 443 已经被既有服务占用,不能为了 Immich 停掉它。因此 Immich 使用单独的 8443 端口,已有的 80 和 443 保持不动。最终客户端使用的地址形如:

https://<PUBLIC_HOST>:8443

其中 <PUBLIC_HOST> 可以是自己的域名,也可以是由 sslip.io 根据公网 IP 生成的主机名。本文不放真实域名。

先做侦察,再动配置

两台服务器上先记录操作系统、架构、内存、磁盘、Docker、监听端口和现有容器。最小检查集可以这样执行:

hostnamectl
uname -a
lscpu
free -h
df -h
docker version
docker compose version
ss -lntup
docker ps
tailscale status

公网网关还要确认 80 和 443 当前由谁监听:

ss -lntup | grep -E ':(80|443|8443)\b'
systemctl --type=service --state=running

我在这次部署中发现,公网网关已有 Caddy,443 则由另一项现有服务占用。这个发现直接决定了后面的方案:不另起一个会抢占 80/443 的代理,而是保留现有服务,增加一个独立的 Immich Gateway。

修改已有 Caddyfile 前先保存副本,并保留一份能够直接恢复的归档。不要把“文件已经复制到另一个目录”当成备份,备份至少要记录来源、时间和恢复方式。

家庭数据服务器上的 Immich

目录规划

下面的变量只用于说明结构:

IMMICH_DIR=/srv/immich
DATA_ROOT=/srv/immich-data
EXTERNAL_MEDIA_ROOT=$DATA_ROOT/media/dji/pocket
BACKUP_ROOT=$DATA_ROOT/backups

推荐把 Immich 的托管媒体、数据库、备份和外部原始素材分开:

<DATA_ROOT>/
├── immich/
│   ├── library/
│   ├── database/
│   └── backups/
└── media/
    └── dji/
        └── pocket/

没有独立数据盘时,可以使用现有系统盘,但要先确认剩余空间,并把磁盘容量和数据库增长纳入日常检查。

使用官方 Stable release

Immich 的 Compose 文件要和 release 版本对应。不要直接拿 main 分支的 Compose 文件配旧镜像,也不要使用第三方镜像。

以部署时的 Stable release 为例:

IMMICH_VERSION=<IMMICH_VERSION>
mkdir -p <IMMICH_DIR>
cd <IMMICH_DIR>

curl -fL   "https://github.com/immich-app/immich/releases/download/<IMMICH_VERSION>/docker-compose.yml"   -o docker-compose.yml

curl -fL   "https://github.com/immich-app/immich/releases/download/<IMMICH_VERSION>/example.env"   -o example.env

环境文件只放在服务器上,不要放进公开仓库、博客附件、日志或下载目录:

UPLOAD_LOCATION=<DATA_ROOT>/immich/library
DB_DATA_LOCATION=<DATA_ROOT>/immich/database
IMMICH_VERSION=<IMMICH_VERSION>
DB_USERNAME=postgres
DB_DATABASE_NAME=immich
DB_PASSWORD=<RANDOM_ALPHANUMERIC_PASSWORD>
EXTERNAL_MEDIA_LOCATION=<DATA_ROOT>/media/dji/pocket

密码应单独生成,不能使用 postgres、immich、password 这类固定值:

openssl rand -hex 24
chmod 600 .env

只读挂载 DJI 原始素材

Immich 托管库和 DJI 原始目录不要混在一起。External Library 只读挂载:

services:
  immich-server:
    volumes:
      - ${EXTERNAL_MEDIA_LOCATION}:/external/dji:ro

这里的 :ro 很重要。扫描和索引不应该拥有修改原始照片、DNG 或视频的权限。

启动前先检查 Compose 展开结果:

docker compose config --quiet
docker compose pull
docker compose up -d
docker compose ps

四个核心服务都应该处于 running,且健康检查通过:

  • Immich Server
  • Machine Learning
  • PostgreSQL
  • Valkey/Redis

本地 API 探针:

curl -fsS http://127.0.0.1:2283/api/server/ping

预期结果:

{"res":"pong"}

在这一步不要急着导入全部照片。先让空库稳定运行,确认日志、内存、磁盘和数据库都正常。

用 Tailscale 连接两台服务器

手机不加入 Tailnet,只有两台服务器加入。公网网关通过家庭服务器的 Tailscale 地址访问 Immich:

OCI → http://<TAILSCALE_HOME_IP>:2283

两端都做一次连通性检查:

tailscale ping <TAILSCALE_HOME_IP>
tailscale ping <TAILSCALE_GATEWAY_IP>

然后在 OCI 上连续请求家庭服务器的 API:

for i in 1 2 3 4 5; do
  curl --noproxy '*'     --connect-timeout 8     --max-time 15     -fsS -o /dev/null     -w '%{http_code} %{time_total}\n'     http://<TAILSCALE_HOME_IP>:2283/api/server/ping
done

每次都返回 200,才说明网关到 Immich 的实际 HTTP 链路是通的。单独看到 tailscale status 里的在线状态还不够,必须走一遍真实上游请求。

如果路径偶尔经过 DERP,不要把它当成失败。先看连续请求是否稳定,再根据延迟决定是否继续排查直连路径。

OCI 公网 HTTPS 网关

为什么没有直接占用 443

已有 443 服务不能被 Immich 覆盖。实际部署采用:

  • 现有 Caddy 继续监听 80。
  • 新建独立的 Immich Gateway,监听 8443。
  • 80 上只对 Immich 主机名做跳转。
  • Immich Gateway 把普通请求代理到家庭服务器的 Tailscale IP。
  • /download/ 保留在 OCI 本地,不回源到家庭服务器。

网关 Caddyfile 的核心结构如下:

{
    http_port 8080
    https_port 8443
}

https://<PUBLIC_HOST>:8443 {
    encode gzip zstd

    handle_path /download/* {
        root * /srv/public-downloads
        file_server
    }

    handle {
        reverse_proxy http://<TAILSCALE_HOME_IP>:2283
    }
}

网关容器需要把宿主机的 8443 映射到容器的 8443,并挂载本地下载目录。网关和现有 Caddy 要加入同一个 Docker 网络,方便 HTTP-01 challenge 通过现有 80 端口转发到网关内部的 8080。

现有 Caddy 的 Immich 相关路由可以按下面的结构组织:

@immich_host host <PUBLIC_HOST>

route {
    handle /.well-known/acme-challenge/* {
        reverse_proxy immich-gateway:8080
    }

    redir @immich_host https://<PUBLIC_HOST>:8443{uri} 308

    handle {
        file_server
    }
}

修改后先做配置检查,再 reload 或重启对应容器。不要直接重启整台服务器,也不要为了测试删除现有 Docker 网络和 Volume。

Let’s Encrypt 验证

证书申请要从公网角度检查:

dig +short <PUBLIC_HOST>
curl -I http://<PUBLIC_HOST>/
curl -fsS https://<PUBLIC_HOST>:8443/api/server/ping

HTTP 应跳转到 8443,HTTPS API 应返回 200。证书的 CN、SAN、签发者和有效期也要检查。

如果 443 被其他服务占用,Caddy 可能先尝试 TLS-ALPN challenge,随后改用 HTTP-01。只要 80 上的 challenge 路由确实可达,最终证书仍可以正常签发。关键是不要为了 ACME 验证停掉现有 443 服务。

公网 APK 下载

Android 用户不需要 Tailscale。OCI 上放一个官方 APK 下载目录:

/srv/public-downloads/
├── immich.apk
├── immich.apk.sha256
├── version.txt
└── previous/

APK 必须来自官方 immich-app/immich release,并排除 RC、Beta、Nightly 和 prerelease。下载完成后计算校验和:

sha256sum immich.apk
printf '%s  immich.apk\n' '<SHA256>' > immich.apk.sha256
sha256sum -c immich.apk.sha256

更新脚本采用手动执行,流程是:

  1. 查询 GitHub 最新 release。
  2. 确认不是 draft 或 prerelease。
  3. 找到官方 universal APK。
  4. 校验下载大小和 SHA256。
  5. 保留当前版本。
  6. 用临时文件完成原子替换。
  7. 更新 version.txt。

不要每天自动更新手机 APK。服务器版本和客户端版本需要在可控时间一起验证。

APK 公网地址的结构是:

https://<PUBLIC_HOST>:8443/download/immich.apk

发布给家人之前,先验证完整下载和 Range 请求:

curl -fL -o /tmp/immich.apk   https://<PUBLIC_HOST>:8443/download/immich.apk

curl -fL   -H 'Range: bytes=0-1048575'   -o /tmp/immich-range.bin   https://<PUBLIC_HOST>:8443/download/immich.apk

下载站点不应该把 APK 请求代理回家里的服务器。这样做可以减少家庭上行带宽和存储服务的压力。

DJI 原始素材导入

导入工具要坚持 COPY_ONLY。输入 SD 卡的 DCIM 目录,输出到按日期分隔的 External Library 目录:

import-dji /path/to/SDCARD/DCIM
import-dji /path/to/SDCARD/DCIM YYYY-MM-DD

工具至少应该做到:

  • 支持 JPG、JPEG、DNG、MP4、MOV。
  • 保留相对文件名。
  • 只复制,不移动,不删除,不格式化。
  • 记录源文件数、目标文件数和总字节数。
  • 对复制结果做 checksum 校验。
  • 把日志写入目标目录。

导入完成后,在 Immich 网页端创建 External Library,文件夹填容器内路径:

/external/dji

Web UI 中的路径是容器路径,不是宿主机路径。原始 DJI 文件仍然只有一份,Immich 负责索引,不需要再复制到托管库。

真实素材导入前,先用少量合成样本验证脚本。没有测试文件时,保持空库比下载陌生媒体更稳妥。

数据库备份和照片备份是两件事

PostgreSQL 至少做每日逻辑备份。备份脚本需要:

  • 使用 pg_dump custom format。
  • 临时文件写入完成后再原子改名。
  • 同时保存 Compose、override、example.env、.env 和备份脚本。
  • 备份文件权限限制为 600。
  • 保留固定天数并定期清理旧备份。
  • 在日志中明确写出 photos_included=false。

可以使用用户级 systemd timer:

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=15m
Unit=immich-db-backup.service

确认 user manager 在没有交互登录时仍能运行,例如检查 user linger。执行一次手动备份,并马上验证 dump 的 checksum:

systemctl --user start immich-db-backup.service
systemctl --user list-timers immich-db-backup.timer
sha256sum -c <BACKUP_FILE>.sha256

数据库备份不包含照片。要达到真正的灾备,还需要把照片和视频复制到另一块物理介质或异地存储,并定期验证恢复。没有验证过,就不要把系统写成已经具备 3-2-1 备份。

验收顺序

验收按链路分层,出错时才知道是哪一跳:

层级检查通过条件
容器docker compose ps四个 Immich 服务 healthy
本机/api/server/pingHTTP 200
Tailnettailscale ping两端可达
上游OCI 请求家庭服务器连续请求全部 200
DNS<PUBLIC_HOST>指向 OCI 公网 IP
HTTPS浏览器和 curl证书有效,API 200
大文件APK 完整下载和 Range大小、校验和、206 都正确
移动端App 登录和上传真机成功完成登录、小文件上传

认证前可以验证 API 和页面。上传、缩略图、视频播放、WebSocket 等功能需要创建管理员账号并导入测试媒体后再验收。服务器本机 curl 通过,不能代替手机真实网络测试。

手机 App 的地址怎么填

Immich 手机 App 的 Server Endpoint URL 填:

https://<PUBLIC_HOST>:8443

不要加 /api,也不要填 APK 下载地址。客户端会自行检查:

https://<PUBLIC_HOST>:8443/api/server/ping

IP 生成的 sslip.io 主机名比较容易手输错。每个 IP 段都要保留,不能把类似 A-B-C-D.sslip.io 写成缺少一个段的变体。遇到 UnknownHostException 时,先在手机浏览器用同一网络打开 /api/server/ping,再看 App 日志里的实际主机名。

如果日志变成 GET /server/version,同时提示没有 http 或 https scheme,通常是 App 没保存完整的绝对 URL,或者保留了上一次失败的本地状态。确认没有待上传照片后清除 App 存储,再重新粘贴完整地址。这个错误发生在客户端构造请求时,不需要先改服务器。

给家人创建账号和分享相册

需要让对方长期使用 App、自动备份手机照片时,在网页端用管理员账号打开:

https://<PUBLIC_HOST>:8443/admin/users

每个人创建一个独立的 Immich 用户。账号需要唯一的邮箱地址作为登录名,不一定要专门新建邮箱,但最好使用对方能控制的真实邮箱,方便找回密码。所有用户使用同一个 Server Endpoint,各自使用自己的账号。

只想临时分享一个相册时,可以创建 Shared Link。对方不需要注册邮箱,拿到链接即可查看。共享链接相当于持有链接即可访问,适合有限范围的分享,敏感内容不要长期公开。

回滚和安全边界

部署时我保留了这些边界:

  • 不公开家庭服务器的 2283。
  • 不公开 PostgreSQL 5432 和 Valkey 6379。
  • 不修改家庭路由器端口转发,不启用 UPnP。
  • 不替换现有 80/443 服务。
  • 不删除既有 Docker 网络、Volume 和服务。
  • 不让 Immich 写入 DJI 原始目录。
  • 不把 .env、数据库密码、SSH 私钥或管理接口暴露到 Web。
  • 停止公网入口时只停止独立的 Immich Gateway。
  • 删除 Immich 容器时不使用 docker compose down -v,也不删除照片根目录。

回滚之前先确认备份文件、原 Caddyfile 和当前 Compose 文件都在。恢复公网入口和恢复照片数据是两件事,不能因为撤掉代理就顺手删除数据。

这次部署留下的几个判断

第一,家庭数据和公网入口分开以后,公网层只需要解决 HTTPS、代理和下载,照片数据仍然留在家里。第二,已有服务占用 443 时,使用 8443 比强行接管端口更容易控制风险。第三,验证要沿着真实链路走,容器健康不等于手机能登录,服务器 curl 也不等于移动网络可用。第四,数据库备份和照片备份必须分开描述,前者通过了,不能替后者背书。

核心链路跑通后,日常维护只剩几件事:观察磁盘空间,检查数据库备份,按需更新 Immich 和 APK,确认手机端仍能登录。新增家庭成员时创建独立账号,分享临时相册时使用可撤销的链接。

参考资料

  • Immich 官方仓库:https://github.com/immich-app/immich
  • Immich Docker 安装文档:https://docs.immich.app/install/docker-compose
  • Immich 管理文档:https://immich.app/docs/administration/user-management
  • Caddy 官方文档:https://caddyserver.com/docs/
  • Tailscale 官方文档:https://tailscale.com/kb/
  • sslip.io:https://sslip.io/