
A four-VM Immich lab: app + ML worker + Postgres-pgvector + Redis, from one prompt
March 1, 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.
The project crossed a real line in late 2025: Immich shipped v2.0.0, its first stable release, on October 1, 2025. “Stable” here is not marketing — it means the team adopted semantic versioning and a compatibility contract, so any v2.x mobile app works with any v2.x server. For years the honest answer to “is Immich ready to replace Google Photos?” was “almost.” In 2026 it is simply yes for home use, with the remaining gaps narrowed to video-transcode quality and shared-album collaboration with people who don't have accounts.
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 a pgvector-class extension for vector search, and a Redis queue — all generated from a single prompt and shipped as bootable ISOs. The recipe is a preparation lab: it lays down the exact topology, ports, env files, and a vector schema, then proves the wiring with compatibility stubs so you can swap in the real Node.js server and ML container with nothing left to guess about the network.
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.embeddings (asset_id UUID, embedding VECTOR(512)) table and the bootstrap SQL ship on first boot. Real Immich runs the same migrations on top — the lab just proves Postgres is listening, the role exists, and the subnet ACL lets the app in before you ever pull a multi-gigabyte container.x86-64-v2 CPU, so isolating it makes the hardware requirement explicit instead of a surprise at deploy time.Four Debian Trixie VMs on 10.71.0.0/24. The Immich server is the only node the phone and browser talk to; it fans requests out to all three backends. The ML worker also writes embeddings back into Postgres, which is what turns a folder of JPEGs into a searchable, face-clustered library.
immich-server:2283; it fans out to Postgres, Redis, and the ML worker. The dashed edge is the ML worker writing embeddings back into Postgres.Why a separate ML node? Smart Search and Facial Recognition are the two features that make Immich feel like Google Photos, and both run as a two-stage pipeline — detect faces, then turn each face (and each photo) into a 512-dimension embedding vector. Crucially, all of that runs on your hardware: no image data, no face data, and no embeddings ever leave your server. That inference is the heaviest, burstiest workload in the stack, which is exactly why pinning it to its own VM (and, in production, the box with the GPU) keeps the web UI snappy while a backlog of 50,000 photos gets indexed.
Those embeddings have to land somewhere queryable. In 2025 Immich migrated its vector backend from pgvecto.rs to VectorChord, a successor extension that delivers higher search throughput and lower memory use, and it works on top of pgvector 0.7–0.9. The lab seeds a VECTOR(512) schema and the CREATE EXTENSION bootstrap so the real install can run its migrations against a database that already has the right shape, role, and subnet ACL. If you bring a pre-existing Postgres rather than the bundled one, this is the layer to get right — mismatched extension versions are the most common reason a fresh Immich upgrade refuses to index.
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 volume plus a consistent pg_dump at the same cadence, replicated to a second disk and pushed off-site. Capture the originals and the database together — photos restored against a stale database leave you with files Immich can't place in the timeline.Can I just use Nextcloud Memories instead of a separate Immich stack? If you already run Nextcloud, try Nextcloud Memories first — your accounts and permissions carry over, and it's one fewer service to babysit. But its face and object recognition run noticeably slower than Immich's dedicated ML container on the same hardware, because Memories piggybacks on Nextcloud's file structure rather than being built ground-up for media. Photo-first households land on Immich; Immich alongside Nextcloud is a perfectly normal setup.
Do I need a GPU? No — ML runs on CPU and the lab's 4-core sizing reflects that. A GPU (CUDA is the most reliable backend) mainly shortens the initial index of a big back-catalogue from hours to minutes; steady-state, a few new photos a day barely register. Plan the GPU for migration day, not forever.
How much disk? Budget your raw library size plus 10–20% — Immich's docs note thumbnails and transcodes add that much on top of your originals.
Photos are step one; documents are step two. The Paperless-ngx document lab gives you the same shape for paper, and the Vaultwarden password vault rounds out the self-hosted core. For verifying the kernel and userspace under the stack, see the runtime attestation post. The Enterprise & GxP page covers fleet-scale rollouts, and pricing covers what it costs to build these on managed infrastructure instead of your own runner.
OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.