
A four-VM Immich lab: app + ML worker + Postgres-pgvector + Redis, from one prompt
May 7, 2026
Immich is the self-hosted answer to Google Photos. It does the things you actually use the cloud for — automatic mobile backup, face recognition, smart search, shared albums — and keeps the library on hardware you own. It's the second-most-deployed app in the 2026 r/selfhosted survey, and the most-planned-next.
This post walks through building the Immich stack on OpenFactory: four buildable VMs — the Immich app server, the ML worker for embeddings and face recognition, Postgres with pgvector for vector search, and a Redis queue — all generated from a single prompt and shipped as bootable ISOs.
immich-server (10.71.0.10:2283) — the upload + library API the mobile and web clients talk to.immich-ml (10.71.0.20:3003) — embedding and face-recognition worker, split out so it can scale (or run on a GPU box) independently.postgres-pgvector (10.71.0.30:5432) — Postgres pre-configured for Immich plus a sample embeddings (asset_id UUID, embedding VECTOR(512)) schema and the bootstrap SQL for the vectors extension.redis (10.71.0.40:6379) — queue for upload, transcoding, and ML pipeline jobs.Four Debian Trixie VMs on 10.71.0.0/24. The Immich server talks to all three backends; the ML worker also writes embeddings back into Postgres.
Paste this verbatim into the chat builder at console.openfactory.tech. Nothing above or below it — the builder expects the prompt body to start at the “Build a compact multi-node lab…” line.
Build a compact multi-node lab named `immich-photo-vault`.
Output discipline: keep the plan small. Use one startup script per node, about 25 shell lines or less. Do not install the real Immich server, machine-learning model weights, PyTorch/TensorFlow, libvips, ffmpeg, pgvector binaries, or external `apt` repos. Do not pull GB-scale ML assets at build time. Write deployment-time config examples and tiny Python stdlib or shell compatibility stubs only. The goal is a buildable preparation lab, not a production deployment.
## Topology
Create 4 buildable `debian-trixie` nodes, all `x86_64`, SSH enabled, DHCP/default route intact with lab aliases, firewall disabled, DNS `1.1.1.1` and `8.8.8.8`, user `ops` password `immich-ops` in `sudo`. Every recipe must set top-level `test_config` to `{ "enabled": false, "tests": [] }`.
- `immich-server`: role `app`, 4 GB RAM, 24 GB disk, alias `10.71.0.10/24`, x `230`, y `60`
- `immich-ml`: role `ml-worker`, 4 GB RAM, 24 GB disk, alias `10.71.0.20/24`, x `110`, y `220`
- `postgres-pgvector`: role `database`, 2 GB RAM, 16 GB disk, alias `10.71.0.30/24`, x `350`, y `220`
- `redis`: role `queue`, 1 GB RAM, 8 GB disk, alias `10.71.0.40/24`, x `230`, y `380`
Connections: `immich-server` to `postgres-pgvector:5432`, `redis:6379`, `immich-ml:3003`; `immich-ml` to `postgres-pgvector:5432` as embeddings-write intent.
## Common Recipe Requirements
All nodes: features `headless`, `ssh`; packages `openssh-server`, `python3`, `curl`, `jq`, `iproute2`, `netcat-openbsd`, `ca-certificates`. Each startup script adds the alias with `IFACE=$(ip route show default | awk '{print $5; exit}')`, `ip link set "$IFACE" up || true`, and `ip addr add <alias> dev "$IFACE" || true`. If `os.startup_scripts[].after` is present, it must be the string `"network-online.target"`, not an array.
## Node Requirements
`immich-server`: features `headless`, `ssh`. Write `/etc/immich/server.env` with `IMMICH_PORT=2283`, `DB_HOSTNAME=10.71.0.30`, `DB_PORT=5432`, `DB_USERNAME=immich`, `DB_DATABASE_NAME=immich`, `REDIS_HOSTNAME=10.71.0.40`, `IMMICH_MACHINE_LEARNING_URL=http://10.71.0.20:3003`. Create `/var/lib/immich/{upload,library,thumbs}` mode `0750 ops:ops`. Add a Python stdlib service on `0.0.0.0:2283` exposing:
- `GET /api/server-info/ping` -> `200 {"res":"pong"}`
- `GET /api/server-info/version` -> `200 {"major":1,"minor":0,"patch":0,"flavor":"compat"}`
- `GET /metrics` -> `immich_server_compat_up 1`
Register `immich-server-compat.service`.
`immich-ml`: features `headless`, `ssh`, `python`. Write `/etc/immich/ml.env` with `MACHINE_LEARNING_PORT=3003`, `MACHINE_LEARNING_CACHE_FOLDER=/var/lib/immich-ml/cache`. Create `/var/lib/immich-ml/{cache,models}` mode `0750 ops:ops`. Add a Python stdlib service on `0.0.0.0:3003` exposing `GET /ping` -> `200 {"status":"ok"}`, `POST /predict` -> `200 {"prediction":"compat-stub","confidence":0.0}` for any JSON body, and `GET /metrics` with `immich_ml_compat_up 1`. Register `immich-ml-compat.service`. Write `/root/immich-ml-notes.md` warning that real ML inference requires CLIP/face-recognition model weights and CUDA or CPU-vectorized ONNX runtime at deployment time.
`postgres-pgvector`: features `headless`, `ssh`, `postgresql`; packages `postgresql`, `postgresql-client`. Configure Postgres to `listen_addresses = '*'` on port `5432`, best-effort create role/database `immich` password `immich` with `host immich immich 10.71.0.0/24 md5` in `pg_hba.conf`. Write `/root/immich-pgvector-bootstrap.sql` containing `CREATE EXTENSION IF NOT EXISTS vectors;` and a sample `embeddings (id BIGSERIAL PRIMARY KEY, asset_id UUID, embedding VECTOR(512))` schema. Expose a tiny `:9187/metrics` listener with `pg_compat_up 1`.
`redis`: features `headless`, `ssh`, `redis`; packages `redis-server`, `redis-tools`. Enable `redis-server`, bind to `127.0.0.1` plus `10.71.0.40`. Add a Python stdlib `:9121/metrics` listener with `redis_compat_up 1`.
## Scenario
Emit exactly one group scenario named `immich-photo-vault-validation`. Put `custom_tests[].assertions[]` inside the scenario entry; leave `scenarios[].tests` empty. Every assertion needs `on_vm`. Use only `port_listening`, `command_output`, and `http_responds`; do not emit `vm_boots`, `network_reachable`, or `service_running`.
- `Stack ports listen`: `port_listening` for `immich-server:2283`, `immich-ml:3003`, `postgres-pgvector:5432`, `postgres-pgvector:9187`, `redis:6379`, `redis:9121`.
- `Server pings`: on `immich-server`, `curl -fsS http://localhost:2283/api/server-info/ping | jq -e '.res == "pong"' >/dev/null && echo server-ok`.
- `ML pings`: on `immich-ml`, `curl -fsS http://localhost:3003/ping | jq -e '.status == "ok"' >/dev/null && echo ml-ok`.
- `Server reaches backends`: on `immich-server`, `nc -z -w 5 10.71.0.30 5432 && nc -z -w 5 10.71.0.40 6379 && nc -z -w 5 10.71.0.20 3003 && echo backends-reachable`.
- `Postgres bootstrap SQL present`: on `postgres-pgvector`, `grep -q 'CREATE EXTENSION' /root/immich-pgvector-bootstrap.sql && echo bootstrap-ready`.
Preserve warnings that real Immich server binary distribution, ML model weight provisioning (CLIP + face recognition), pgvector / `vectors` extension installation, mobile-app auth tokens, S3 / object storage backends, library import scheduling, thumbnail generation, library backup, and `10.71.0.0/24` aliasing are deployment-time concerns.Driving OpenFactory from an AI agent instead of the browser? The same flow is exposed through the OpenFactory MCP server — submit the prompt programmatically, get the build-plan preview back, and call create_build / start_vm on the resulting recipes. Single-image builds go straight through the openfactory CLI.
The prompt produces a buildable preparation lab — the right topology, the right ports listening, deployment-time config templates dropped in the right places, and tiny compatibility services that prove the wiring works. A few things still sit outside the recipe and need operator attention before this carries real load:
/var/lib/immich/upload and the Postgres volume both need off-host snapshots; losing either loses the library.Photos are step one; documents are step two. The Paperless-ngx document lab gives you the same shape for paper. For verifying the kernel and userspace under the stack, see the runtime attestation post. And the Enterprise & GxP page covers fleet-scale rollouts.
OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.