My Immich server runs on an older Dell box that never had much of a GPU to speak of, just enough for transcoding Jellyfin. Photos upload fine. Thumbnails generate fine. The moment the machine learning jobs kick in, the queue backs up and stays backed up. Face detection, smart search embeddings, OCR, all of it running on a CPU that was never built for the job.
I'd been chipping away at the queue for months. A few hundred photos would process overnight, then I'd add a few thousand more from an old backup drive and watch the counter barely move. At that rate I was going to be waiting months to get a fully searchable library.
Immich has a built-in answer for underpowered hardware. You can point the machine learning container at a separate, more capable machine and let the server talk to it over the network instead of processing locally. It's called remote machine learning, and the use case is right there in the docs: pair a weak NAS with whatever stronger box happens to be sitting on the same network.
I had two candidates sitting around. A gaming PC with an NVIDIA card that mostly sits idle outside of a few nights a week, and my M4 MacBook Pro with 24GB of RAM. The Mac won mostly because it's quieter and I didn't need to keep a tower running just to burn through a backlog. Either one would have worked. The point of remote machine learning is that Immich doesn't care what's on the other end, as long as it can serve requests.
The catch with GPUs on macOS
Docker on Apple Silicon can't reach the GPU. That's not an Immich limitation, it's a Docker Desktop and container runtime limitation on macOS in general. Metal, the Neural Engine, CoreML, none of it is exposed to a container. So if you spin up Immich's standard immich-machine-learning image on a Mac, you get CPU-only inference no matter how fast the chip is.
People have been asking about this in the Immich GitHub discussions for a couple of years now. The maintainers were straightforward about it: hardware acceleration can't be passed through to a container on ARM Mac hardware. If you want the GPU, you have to run the ML service natively, outside Docker entirely.
Where we got stuck first
My first attempt at remote machine learning was the plain version straight from the Immich docs. Stand up the immich-machine-learning container on the Mac, point the server at it, done. It worked, technically. It was also barely faster than running the queue locally on the Dell box, because that container ships a Python and ONNX Runtime stack built for x86 with optional CUDA, not for Apple Silicon. On a Mac it just falls back to plain CPU inference inside the container, the same bottleneck I was trying to get away from.
Getting past that meant stepping outside Docker entirely and building a native ML service that could reach Metal and the Neural Engine directly. That's a real project on its own: cloning Immich's machine learning code, swapping in CoreML execution providers, installing Poetry, wiring up a separate model cache, and keeping all of it in sync every time Immich itself gets updated. A few people in the Immich discussions had documented doing exactly this by hand, and it's not a small undertaking to maintain solo.
Immich Accelerator
A project called Immich Accelerator does that work for you. It's a Homebrew-installed tool that extracts Immich's own microservices worker directly from your running Docker image, then runs it natively on macOS alongside a separate ML service built on Apple's frameworks.
The split looks like this. Docker keeps the lightweight pieces: the API server, Postgres, Redis. All of that stays wherever it already lives, in my case on Unraid. The Mac runs the parts that chew through CPU or GPU cycles.
CLIP embeddings for smart search run on the Metal GPU through MLX. Face detection and OCR run on the Neural Engine through Apple's Vision framework. Face recognition uses ONNX with CoreML acceleration layered on top. Video transcoding gets remapped from software encoding to VideoToolbox hardware encoding.
None of this touches the Docker image itself. The tool doesn't patch Immich or rebuild anything, it just extracts the worker code that's already sitting in the container you're running, so it always matches your version exactly.
Getting it running
Mount the media share first, before running setup, so the Mac sees the same path your Docker host uses. I'm mounting the same SMB share Unraid exports:
mkdir -p ~/immich-media
mount_smbfs //user@your-unraid-host/media ~/immich-mediaThen install the accelerator and point it at the remote server:
brew install epheterson/immich-accelerator/immich-accelerator
brew trust epheterson/immich-accelerator
immich-accelerator setup --url http://your-unraid-host:2283 --api-key YOUR_KEYThere's one hard requirement for this split setup, and it's the one that trips people up. Both machines need to see the exact same media files at the exact same absolute path. Immich stores paths like /data/library/<uuid>/... in Postgres, and if the Mac's mount point doesn't match, thumbnails will 404 even though the worker itself is happily processing jobs.
Run it as a background service rather than a one-off foreground process, so it survives reboots and picks up Immich version updates on its own:
brew services start epheterson/immich-accelerator/immich-acceleratorA few commands come in handy once it's running. Check that everything came up healthy:
immich-accelerator statusConfirm the ML service can actually reach Metal and the Neural Engine, not just that it's alive:
immich-accelerator ml-testTail the logs if a job gets stuck:
immich-accelerator logs ml
immich-accelerator logs workerWatch throughput without opening the Immich admin panel through the built-in dashboard on port 8420:
immich-accelerator dashboardOpen http://your-mac-hostname:8420 from any device on the network, including your phone, to watch the queue drain in real time.
If you ever need to back out, stop the service and remove the environment variables and exposed ports from your Docker host's compose file:
brew services stop epheterson/immich-accelerator/immich-accelerator
immich-accelerator uninstallTuning concurrency
Once it's running, the per-queue concurrency settings in Immich's admin panel matter more than they do with a single Docker ML container. GPU work and Neural Engine work don't scale the same way CPU work does.
Smart search, which runs on the Metal GPU, doesn't benefit from more than two concurrent jobs since MLX serializes GPU access anyway. Face detection and OCR, both running on the Neural Engine, handle three concurrent jobs reasonably well. Thumbnail generation and metadata extraction are CPU or I/O bound and scale higher, up to four.
Cranking everything to the same high number just causes CPU thrashing without moving the needle on throughput.
What's different from Docker
The worker itself runs Immich's unmodified code, so the differences are almost entirely in the ML layer. CLIP search results are close but not identical to what Docker's ONNX Runtime path produces, since the underlying computation runs through MLX instead. A search that returns twenty results in Docker might return eighteen or nineteen of the same twenty, occasionally in a different order.
Face grouping can land slightly differently at the edges too, since Apple's Vision framework is a different model from the one ONNX Runtime uses. It hasn't caused a real problem for my library. The photos still get found, the faces still get grouped, and the jobs finish in a fraction of the time they used to take.
This isn't just a Mac problem
If you've got a Windows or Linux box with a real NVIDIA card instead of a Mac, you actually have it easier. Docker on those platforms can pass GPU access straight through to a container, no native workaround required. The standard remote machine learning setup handles it with a single tag change and a small hardware acceleration file.
On the remote machine, the compose file looks like this:
name: immich_remote_ml
services:
immich-machine-learning:
container_name: immich_machine_learning
image: ghcr.io/immich-app/immich-machine-learning:release-cuda
extends:
file: hwaccel.ml.yml
service: cuda
volumes:
- model-cache:/cache
restart: always
ports:
- 3003:3003
volumes:
model-cache:Pull the matching hwaccel.ml.yml from Immich's repo, point your main server's IMMICH_MACHINE_LEARNING_URL at that machine's address on port 3003, and the queue drains at full GPU speed with none of the native extraction steps a Mac needs. AMD boxes get a similar path through the ROCm tag, and Intel Arc or integrated graphics can use OpenVINO.
The Mac route exists because Apple Silicon can't take the easy path. If you're choosing which spare machine in the house gets pulled into service, that's worth weighing. A Windows or Linux box with a GPU is plug and play. A Mac gets you there too, it just takes one extra piece of software to unlock what the hardware can already do.
Links if you're doing this on Windows
Immich only documents this for Linux and Windows through WSL2, not Docker Desktop's Hyper-V backend directly, so make sure Docker is running through WSL2 before starting.
- Hardware-Accelerated Machine Learning is the official Immich page covering every backend below, including the exact
docker-compose.ymlchanges for each one hwaccel.ml.ymlis the file referenced in the compose snippet above. Grab the latest release build rather than an old copy floating around a forum post- NVIDIA Container Toolkit install guide walks through getting an NVIDIA card visible inside a container. This is the step most people skip and then wonder why the
cudatag doesn't do anything - ROCm Docker install guide covers the AMDGPU driver setup ROCm needs. Its official support targets Linux, so an AMD card on Windows through WSL2 is going to be rougher terrain than the NVIDIA path
Intel doesn't need a separate link. OpenVINO setup for Arc and integrated graphics is covered on the same hardware acceleration page above, including the WSL2-specific device mapping for /dev/dri and /dev/dxg.
You don't need to run this all the time
The heavy lift here is almost entirely a backlog problem. Once a library is caught up, new photos trickle in a handful at a time and even a weak CPU keeps pace fine. The remote machine doesn't need to stay online forever, just long enough to chew through whatever's stacked up in the queue.
That makes it a good candidate for batching. Fire up the accelerator or the remote ML container when you know you're about to dump a few thousand photos in, whether that's after digitizing an old hard drive or importing a family member's entire library. Let it run overnight or over a weekend, then shut it down until the next big batch shows up.
This matters even more if your GPU is already busy doing something else. A lot of homelab GPUs spend their days on Jellyfin or Plex transcoding, and running Immich's ML jobs on the same card at the same time means the two are fighting over VRAM and compute. Adding a second machine, even a laptop that's only free in the evenings, keeps transcoding and photo processing from stepping on each other and gets a big backlog done in a single dedicated push instead of dragging it out over weeks of contention.
Having Home Assistant tell you when it's time
The trickiest part of batching is remembering to do it. Nobody thinks about their Immich queue until they get back from vacation and dump four thousand photos into the library at once. Home Assistant can keep an eye on that so I don't have to.
I had Claude Code wire this up instead of hand typing it myself, and walking through what went wrong along the way is more useful than just handing over the finished YAML. Nothing here was exotic, but almost every shortcut from an older tutorial needed a second look before it worked.
First, confirm what the API returns before writing a single sensor. Immich's queue endpoint lives at GET /api/jobs:
curl -s -H "x-api-key: YOUR_KEY" http://your-immich-host:2283/api/jobs | jqOn my version the response nests a waiting count under jobCounts for each of eighteen job types, smart search, face detection, metadata extraction, and a bunch of others I don't care about for this. The field names happened to match what you'd guess. That's luck, not a guarantee. Run the curl yourself instead of trusting a blog post's field names to still match your version.
The REST sensor config has a trap built into it. A lot of older tutorials nest sensors under a platform: rest entry inside the sensor: block. That style validates clean under ha core check and reports success on reload, then silently creates zero entities. Modern Home Assistant wants the sensors nested under a top-level rest: key instead:
rest:
- resource: http://your-immich-host:2283/api/jobs
headers:
x-api-key: !secret immich_api_key
scan_interval: 900
timeout: 10
sensor:
- name: "Immich Smart Search Queue"
unique_id: immich_smart_search_queue
value_template: "{{ value_json.smartSearch.jobCounts.waiting }}"
unit_of_measurement: "jobs"
state_class: measurement
- name: "Immich Face Detection Queue"
unique_id: immich_face_detection_queue
value_template: "{{ value_json.faceDetection.jobCounts.waiting }}"
unit_of_measurement: "jobs"
state_class: measurement
- name: "Immich Metadata Extraction Queue"
unique_id: immich_metadata_extraction_queue
value_template: "{{ value_json.metadataExtraction.jobCounts.waiting }}"
unit_of_measurement: "jobs"
state_class: measurementThe API key needs job.read permission. If you've already got an Immich integration running in Home Assistant for photo or storage sensors, reuse that key instead of minting a new one, just make sure it lands in secrets.yaml cleanly. A key appended by a script inherited a couple of stray trailing spaces from the end of the file once, which indented it one level and quietly broke the top-level mapping. Re-read the file after any scripted edit. Don't just trust that the write succeeded.
A template sensor rolls the three queues into one backlog number, which is easier to reason about than three separate ones:
- sensor:
- name: "Immich Total Queue Backlog"
unique_id: immich_total_queue_backlog
unit_of_measurement: "jobs"
state_class: measurement
state: >
{{ (states('sensor.immich_smart_search_queue') | int(0))
+ (states('sensor.immich_face_detection_queue') | int(0))
+ (states('sensor.immich_metadata_extraction_queue') | int(0)) }}Notifications are the other place where old habits bite. A lot of tutorials still call notify.mobile_app_<devicename> directly. Recent Home Assistant versions moved to an entity-based notify architecture, so the call is notify.send_message with a target: entity_id: pointing at the specific notify entity instead. Check which notify entities actually exist and which one carries a real last-sent timestamp before assuming a name.
- id: immich_queue_backlog_alert_0001
alias: Immich Queue Backlog Alert
description: >-
Alerts when the Immich ML job queue backlog stays above threshold for a
sustained period, indicating a real stuck backlog rather than a normal
post-upload spike.
triggers:
- trigger: numeric_state
entity_id: sensor.immich_total_queue_backlog
above: 500
for:
hours: 1
actions:
- action: notify.send_message
target:
entity_id: notify.your_phone_notify_entity
data:
title: "Immich queue is piling up"
message: >-
Immich backlog has been above 500 jobs for over an hour
({{ states('sensor.immich_total_queue_backlog') }} jobs waiting).
Might be worth spinning up the remote GPU worker.
mode: singleThe for: hours: 1 matters more than it looks like it does. A big backlog appears the instant an upload finishes, and it'll clear itself out over the next hour if the queue is already moving at a normal pace. Requiring the number to stay above the threshold for an hour filters out that normal churn and only pings you when the backlog is actually stuck.
Testing that hold is its own small puzzle. A numeric_state trigger's for: timer only starts counting from an actual state change, not from a value that was already true when Home Assistant reloaded. Forcing the sensor to a fake threshold doesn't trip it if the queue never moves. The honest way to confirm delivery is to trigger the automation's action directly and check that the notify entity's timestamp actually updates, then set the real threshold back once you know the notification path works.
If writing REST sensors by hand isn't appealing, HASS-IMMICH-API is a custom Home Assistant integration that wraps the same job queue endpoints into ready-made sensors and switches, installed through HACS or the SSH addon instead of hand-rolled YAML.
The beta label is accurate
The project carries a beta warning, and it means it. Back up your Immich database before the first run. I'd also recommend testing on a smaller library or a subset of your uploads before pointing it at years of accumulated photos, just to get comfortable with how the split deployment behaves on your specific network setup.
For a homelab that already has a spare Mac or gaming PC sitting around, or for anyone deciding between buying a GPU for their NAS box versus repurposing hardware that's already on the desk, this closes a gap that's existed in the Immich ecosystem for a while.
I thought I was going to have to deal with this some other way, either by throwing money at the problem or by just dealing with it. I'm glad i can use existing hardware to solve the problem for free.
Did this help you? I'd love to know.
Comments