diff --git a/.github/actions/image-build/action.yml b/.github/actions/image-build/action.yml
new file mode 100644
index 0000000000..64a06ec141
--- /dev/null
+++ b/.github/actions/image-build/action.yml
@@ -0,0 +1,118 @@
+name: 'Single arch image build'
+description: 'Build single-arch image on platform appropriate runner'
+inputs:
+  image:
+    description: 'Name of the image to build'
+    required: true
+  ghcr-token:
+    description: 'GitHub Container Registry token'
+    required: true
+  platform:
+    description: 'Platform to build for'
+    required: true
+  artifact-key-base:
+    description: 'Base key for artifact name'
+    required: true
+  context:
+    description: 'Path to build context'
+    required: true
+  dockerfile:
+    description: 'Path to Dockerfile'
+    required: true
+  build-args:
+    description: 'Docker build arguments'
+    required: false
+runs:
+  using: 'composite'
+  steps:
+    - name: Prepare
+      id: prepare
+      shell: bash
+      env:
+        PLATFORM: ${{ inputs.platform }}
+      run: |
+        echo "platform-pair=${PLATFORM//\//-}" >> $GITHUB_OUTPUT
+
+    - name: Set up Docker Buildx
+      uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
+
+    - name: Login to GitHub Container Registry
+      uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
+      if: ${{ !github.event.pull_request.head.repo.fork }}
+      with:
+        registry: ghcr.io
+        username: ${{ github.repository_owner }}
+        password: ${{ inputs.ghcr-token }}
+
+    - name: Generate cache key suffix
+      id: cache-key-suffix
+      shell: bash
+      env:
+        REF: ${{ github.ref_name }}
+      run: |
+        if [[ "${{ github.event_name }}" == "pull_request" ]]; then
+          echo "cache-key-suffix=pr-${{ github.event.number }}" >> $GITHUB_OUTPUT
+        else
+          SUFFIX=$(echo "${REF}" | sed 's/[^a-zA-Z0-9]/-/g')
+          echo "suffix=${SUFFIX}" >> $GITHUB_OUTPUT
+        fi
+
+    - name: Generate cache target
+      id: cache-target
+      shell: bash
+      env:
+        BUILD_ARGS: ${{ inputs.build-args }}
+        IMAGE: ${{ inputs.image }}
+        SUFFIX: ${{ steps.cache-key-suffix.outputs.suffix }}
+        PLATFORM_PAIR: ${{ steps.prepare.outputs.platform-pair }}
+      run: |
+        if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
+          # Essentially just ignore the cache output (forks can't write to registry cache)
+          echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
+        else
+          HASH=$(sha256sum <<< "${BUILD_ARGS}" | cut -d' ' -f1)
+          CACHE_KEY="${PLATFORM_PAIR}-${HASH}"
+          echo "cache-key-base=${CACHE_KEY}" >> $GITHUB_OUTPUT
+          echo "cache-to=type=registry,ref=${IMAGE}-build-cache:${CACHE_KEY}-${SUFFIX},mode=max,compression=zstd" >> $GITHUB_OUTPUT
+        fi
+
+    - name: Generate docker image tags
+      id: meta
+      uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
+      env:
+        DOCKER_METADATA_PR_HEAD_SHA: 'true'
+
+    - name: Build and push image
+      id: build
+      uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
+      with:
+        context: ${{ inputs.context }}
+        file: ${{ inputs.dockerfile }}
+        platforms: ${{ inputs.platform }}
+        labels: ${{ steps.meta.outputs.labels }}
+        cache-to: ${{ steps.cache-target.outputs.cache-to }}
+        cache-from: |
+          type=registry,ref=${{ inputs.image }}-build-cache:${{ steps.cache-target.outputs.cache-key-base }}-${{ env.CACHE_KEY_SUFFIX }}
+          type=registry,ref=${{ inputs.image }}-build-cache:${{ steps.cache-target.outputs.cache-key-base }}-main
+        outputs: type=image,"name=${{ inputs.image }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }}
+        build-args: |
+          BUILD_ID=${{ github.run_id }}
+          BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.meta.outputs.tags }}
+          BUILD_SOURCE_REF=${{ github.ref_name }}
+          BUILD_SOURCE_COMMIT=${{ github.sha }}
+          ${{ inputs.build-args }}
+
+    - name: Export digest
+      shell: bash
+      run: | # zizmor: ignore[template-injection]
+        mkdir -p ${{ runner.temp }}/digests
+        digest="${{ steps.build.outputs.digest }}"
+        touch "${{ runner.temp }}/digests/${digest#sha256:}"
+
+    - name: Upload digest
+      uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+      with:
+        name: ${{ inputs.artifact-key-base }}-${{ steps.cache-target.outputs.cache-key-base }}
+        path: ${{ runner.temp }}/digests/*
+        if-no-files-found: error
+        retention-days: 1
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 1746c93bc7..056aa7e6f4 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -40,6 +40,8 @@ jobs:
               - 'machine-learning/**'
             workflow:
               - '.github/workflows/docker.yml'
+              - '.github/workflows/multi-runner-build.yml'
+              - '.github/actions/image-build'
 
       - name: Check if we should force jobs to run
         id: should_force
@@ -103,429 +105,74 @@ jobs:
           docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_PR}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
           docker buildx imagetools create -t "${REGISTRY_NAME}/${REPOSITORY}:${TAG_COMMIT}" "${REGISTRY_NAME}/${REPOSITORY}:${TAG_OLD}"
 
-  build_and_push_ml:
+  machine-learning:
     name: Build and Push ML
     needs: pre-job
-    permissions:
-      contents: read
-      packages: write
     if: ${{ needs.pre-job.outputs.should_run_ml == 'true' }}
-    runs-on: ${{ matrix.runner }}
-    env:
-      image: immich-machine-learning
-      context: machine-learning
-      file: machine-learning/Dockerfile
-      GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-machine-learning
     strategy:
-      # Prevent a failure in one image from stopping the other builds
       fail-fast: false
-      matrix:
-        include:
-          - platform: linux/amd64
-            runner: ubuntu-latest
-            device: cpu
-
-          - platform: linux/arm64
-            runner: ubuntu-24.04-arm
-            device: cpu
-
-          - platform: linux/amd64
-            runner: ubuntu-latest
-            device: cuda
-            suffix: -cuda
-
-          - platform: linux/amd64
-            runner: mich
-            device: rocm
-            suffix: -rocm
-
-          - platform: linux/amd64
-            runner: ubuntu-latest
-            device: openvino
-            suffix: -openvino
-
-          - platform: linux/arm64
-            runner: ubuntu-24.04-arm
-            device: armnn
-            suffix: -armnn
-
-          - platform: linux/arm64
-            runner: ubuntu-24.04-arm
-            device: rknn
-            suffix: -rknn
-
-    steps:
-      - name: Prepare
-        run: |
-          platform=${{ matrix.platform }}
-          echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
-
-      - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
-        with:
-          persist-credentials: false
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
-
-      - name: Login to GitHub Container Registry
-        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
-        if: ${{ !github.event.pull_request.head.repo.fork }}
-        with:
-          registry: ghcr.io
-          username: ${{ github.repository_owner }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-
-      - name: Generate cache key suffix
-        env:
-          REF: ${{ github.ref_name }}
-        run: |
-          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
-            echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV
-          else
-            SUFFIX=$(echo "${REF}" | sed 's/[^a-zA-Z0-9]/-/g')
-            echo "CACHE_KEY_SUFFIX=${SUFFIX}" >> $GITHUB_ENV
-          fi
-
-      - name: Generate cache target
-        id: cache-target
-        run: |
-          if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
-            # Essentially just ignore the cache output (forks can't write to registry cache)
-            echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
-          else
-            echo "cache-to=type=registry,ref=${GHCR_REPO}-build-cache:${PLATFORM_PAIR}-${{ matrix.device }}-${CACHE_KEY_SUFFIX},mode=max,compression=zstd" >> $GITHUB_OUTPUT
-          fi
-
-      - name: Generate docker image tags
-        id: meta
-        uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
-        env:
-          DOCKER_METADATA_PR_HEAD_SHA: 'true'
-
-      - name: Build and push image
-        id: build
-        uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
-        with:
-          context: ${{ env.context }}
-          file: ${{ env.file }}
-          platforms: ${{ matrix.platforms }}
-          labels: ${{ steps.meta.outputs.labels }}
-          cache-to: ${{ steps.cache-target.outputs.cache-to }}
-          cache-from: |
-            type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-${{ env.CACHE_KEY_SUFFIX }}
-            type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ matrix.device }}-main
-          outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }}
-          build-args: |
-            DEVICE=${{ matrix.device }}
-            BUILD_ID=${{ github.run_id }}
-            BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }}
-            BUILD_SOURCE_REF=${{ github.ref_name }}
-            BUILD_SOURCE_COMMIT=${{ github.sha }}
-
-      - name: Export digest
-        run: | # zizmor: ignore[template-injection]
-          mkdir -p ${{ runner.temp }}/digests
-          digest="${{ steps.build.outputs.digest }}"
-          touch "${{ runner.temp }}/digests/${digest#sha256:}"
-
-      - name: Upload digest
-        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
-        with:
-          name: ml-digests-${{ matrix.device }}-${{ env.PLATFORM_PAIR }}
-          path: ${{ runner.temp }}/digests/*
-          if-no-files-found: error
-          retention-days: 1
-
-  merge_ml:
-    name: Merge & Push ML
-    runs-on: ubuntu-latest
-    permissions:
-      contents: read
-      actions: read
-      packages: write
-    if: ${{ needs.pre-job.outputs.should_run_ml == 'true' && !github.event.pull_request.head.repo.fork }}
-    env:
-      GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-machine-learning
-      DOCKER_REPO: altran1502/immich-machine-learning
-    strategy:
       matrix:
         include:
           - device: cpu
+            tag-suffix: ''
           - device: cuda
-            suffix: -cuda
-          - device: rocm
-            suffix: -rocm
+            tag-suffix: '-cuda'
+            platforms: linux/amd64
           - device: openvino
-            suffix: -openvino
+            tag-suffix: '-openvino'
+            platforms: linux/amd64
           - device: armnn
-            suffix: -armnn
+            tag-suffix: '-armnn'
+            platforms: linux/arm64
           - device: rknn
-            suffix: -rknn
-    needs:
-      - build_and_push_ml
-    steps:
-      - name: Download digests
-        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
-        with:
-          path: ${{ runner.temp }}/digests
-          pattern: ml-digests-${{ matrix.device }}-*
-          merge-multiple: true
-
-      - name: Login to Docker Hub
-        if: ${{ github.event_name == 'release' }}
-        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
-        with:
-          username: ${{ secrets.DOCKERHUB_USERNAME }}
-          password: ${{ secrets.DOCKERHUB_TOKEN }}
-
-      - name: Login to GHCR
-        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
-        with:
-          registry: ghcr.io
-          username: ${{ github.repository_owner }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
-
-      - name: Generate docker image tags
-        id: meta
-        uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
-        env:
-          DOCKER_METADATA_PR_HEAD_SHA: 'true'
-        with:
-          flavor: |
-            # Disable latest tag
-            latest=false
-            suffix=${{ matrix.suffix }}
-          images: |
-            name=${{ env.GHCR_REPO }}
-            name=${{ env.DOCKER_REPO }},enable=${{ github.event_name == 'release' }}
-          tags: |
-            # Tag with branch name
-            type=ref,event=branch
-            # Tag with pr-number
-            type=ref,event=pr
-            # Tag with long commit sha hash
-            type=sha,format=long,prefix=commit-
-            # Tag with git tag on release
-            type=ref,event=tag
-            type=raw,value=release,enable=${{ github.event_name == 'release' }}
-
-      - name: Create manifest list and push
-        working-directory: ${{ runner.temp }}/digests
-        run: |
-          # Process annotations
-          declare -a ANNOTATIONS=()
-          if [[ -n "$DOCKER_METADATA_OUTPUT_JSON" ]]; then
-            while IFS= read -r annotation; do
-              # Extract key and value by removing the manifest: prefix
-              if [[ "$annotation" =~ ^manifest:(.+)=(.+)$ ]]; then
-                key="${BASH_REMATCH[1]}"
-                value="${BASH_REMATCH[2]}"
-                # Use array to properly handle arguments with spaces
-                ANNOTATIONS+=(--annotation "index:$key=$value")
-              fi
-            done < <(jq -r '.annotations[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
-          fi
-
-          TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
-          SOURCE_ARGS=$(printf "${GHCR_REPO}@sha256:%s " *)
-
-          docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS
-
-  build_and_push_server:
-    name: Build and Push Server
-    runs-on: ${{ matrix.runner }}
-    permissions:
-      contents: read
-      packages: write
-    needs: pre-job
-    if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
-    env:
-      image: immich-server
-      context: .
-      file: server/Dockerfile
-      GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server
-    strategy:
-      fail-fast: false
-      matrix:
-        include:
-          - platform: linux/amd64
-            runner: ubuntu-latest
-          - platform: linux/arm64
-            runner: ubuntu-24.04-arm
-    steps:
-      - name: Prepare
-        run: |
-          platform=${{ matrix.platform }}
-          echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
-
-      - name: Checkout
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
-        with:
-          persist-credentials: false
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
-
-      - name: Login to GitHub Container Registry
-        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
-        if: ${{ !github.event.pull_request.head.repo.fork }}
-        with:
-          registry: ghcr.io
-          username: ${{ github.repository_owner }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-
-      - name: Generate cache key suffix
-        env:
-          REF: ${{ github.ref_name }}
-        run: |
-          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
-            echo "CACHE_KEY_SUFFIX=pr-${{ github.event.number }}" >> $GITHUB_ENV
-          else
-            SUFFIX=$(echo "${REF}" | sed 's/[^a-zA-Z0-9]/-/g')
-            echo "CACHE_KEY_SUFFIX=${SUFFIX}" >> $GITHUB_ENV
-          fi
-
-      - name: Generate cache target
-        id: cache-target
-        run: |
-          if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
-            # Essentially just ignore the cache output (forks can't write to registry cache)
-            echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT
-          else
-            echo "cache-to=type=registry,ref=${GHCR_REPO}-build-cache:${PLATFORM_PAIR}-${CACHE_KEY_SUFFIX},mode=max,compression=zstd" >> $GITHUB_OUTPUT
-          fi
-
-      - name: Generate docker image tags
-        id: meta
-        uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
-        env:
-          DOCKER_METADATA_PR_HEAD_SHA: 'true'
-
-      - name: Build and push image
-        id: build
-        uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
-        with:
-          context: ${{ env.context }}
-          file: ${{ env.file }}
-          platforms: ${{ matrix.platform }}
-          labels: ${{ steps.meta.outputs.labels }}
-          cache-to: ${{ steps.cache-target.outputs.cache-to }}
-          cache-from: |
-            type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-${{ env.CACHE_KEY_SUFFIX }}
-            type=registry,ref=${{ env.GHCR_REPO }}-build-cache:${{ env.PLATFORM_PAIR }}-main
-          outputs: type=image,"name=${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=${{ !github.event.pull_request.head.repo.fork }}
-          build-args: |
-            DEVICE=cpu
-            BUILD_ID=${{ github.run_id }}
-            BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }}
-            BUILD_SOURCE_REF=${{ github.ref_name }}
-            BUILD_SOURCE_COMMIT=${{ github.sha }}
-
-      - name: Export digest
-        run: | # zizmor: ignore[template-injection]
-          mkdir -p ${{ runner.temp }}/digests
-          digest="${{ steps.build.outputs.digest }}"
-          touch "${{ runner.temp }}/digests/${digest#sha256:}"
-
-      - name: Upload digest
-        uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
-        with:
-          name: server-digests-${{ env.PLATFORM_PAIR }}
-          path: ${{ runner.temp }}/digests/*
-          if-no-files-found: error
-          retention-days: 1
-
-  merge_server:
-    name: Merge & Push Server
-    runs-on: ubuntu-latest
+            tag-suffix: '-rknn'
+            platforms: linux/arm64
+          - device: rocm
+            tag-suffix: '-rocm'
+            platforms: linux/amd64
+            runner-mapping: '{"linux/amd64": "mich"}'
+    uses: ./.github/workflows/multi-runner-build.yml
     permissions:
       contents: read
       actions: read
       packages: write
-    if: ${{ needs.pre-job.outputs.should_run_server == 'true' && !github.event.pull_request.head.repo.fork }}
-    env:
-      GHCR_REPO: ghcr.io/${{ github.repository_owner }}/immich-server
-      DOCKER_REPO: altran1502/immich-server
-    needs:
-      - build_and_push_server
-    steps:
-      - name: Download digests
-        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
-        with:
-          path: ${{ runner.temp }}/digests
-          pattern: server-digests-*
-          merge-multiple: true
+    secrets:
+      DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+      DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+    with:
+      image: immich-machine-learning
+      context: machine-learning
+      dockerfile: machine-learning/Dockerfile
+      platforms: ${{ matrix.platforms }}
+      runner-mapping: ${{ matrix.runner-mapping }}
+      tag-suffix: ${{ matrix.tag-suffix }}
+      dockerhub-push: ${{ github.event_name == 'release' }}
+      build-args: |
+        DEVICE=${{ matrix.device }}
 
-      - name: Login to Docker Hub
-        if: ${{ github.event_name == 'release' }}
-        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
-        with:
-          username: ${{ secrets.DOCKERHUB_USERNAME }}
-          password: ${{ secrets.DOCKERHUB_TOKEN }}
-
-      - name: Login to GHCR
-        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
-        with:
-          registry: ghcr.io
-          username: ${{ github.repository_owner }}
-          password: ${{ secrets.GITHUB_TOKEN }}
-
-      - name: Set up Docker Buildx
-        uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
-
-      - name: Generate docker image tags
-        id: meta
-        uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
-        env:
-          DOCKER_METADATA_PR_HEAD_SHA: 'true'
-        with:
-          flavor: |
-            # Disable latest tag
-            latest=false
-            suffix=${{ matrix.suffix }}
-          images: |
-            name=${{ env.GHCR_REPO }}
-            name=${{ env.DOCKER_REPO }},enable=${{ github.event_name == 'release' }}
-          tags: |
-            # Tag with branch name
-            type=ref,event=branch
-            # Tag with pr-number
-            type=ref,event=pr
-            # Tag with long commit sha hash
-            type=sha,format=long,prefix=commit-
-            # Tag with git tag on release
-            type=ref,event=tag
-            type=raw,value=release,enable=${{ github.event_name == 'release' }}
-
-      - name: Create manifest list and push
-        working-directory: ${{ runner.temp }}/digests
-        run: |
-          # Process annotations
-          declare -a ANNOTATIONS=()
-          if [[ -n "$DOCKER_METADATA_OUTPUT_JSON" ]]; then
-            while IFS= read -r annotation; do
-              # Extract key and value by removing the manifest: prefix
-              if [[ "$annotation" =~ ^manifest:(.+)=(.+)$ ]]; then
-                key="${BASH_REMATCH[1]}"
-                value="${BASH_REMATCH[2]}"
-                # Use array to properly handle arguments with spaces
-                ANNOTATIONS+=(--annotation "index:$key=$value")
-              fi
-            done < <(jq -r '.annotations[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
-          fi
-
-          TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
-          SOURCE_ARGS=$(printf "${GHCR_REPO}@sha256:%s " *)
-
-          docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS
+  server:
+    name: Build and Push Server
+    needs: pre-job
+    if: ${{ needs.pre-job.outputs.should_run_server == 'true' }}
+    uses: ./.github/workflows/multi-runner-build.yml
+    permissions:
+      contents: read
+      actions: read
+      packages: write
+    secrets:
+      DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+      DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+    with:
+      image: immich-server
+      context: .
+      dockerfile: server/Dockerfile
+      dockerhub-push: ${{ github.event_name == 'release' }}
+      build-args: |
+        DEVICE=cpu
 
   success-check-server:
     name: Docker Build & Push Server Success
-    needs: [merge_server, retag_server]
+    needs: [server, retag_server]
     permissions: {}
     runs-on: ubuntu-latest
     if: always()
@@ -540,7 +187,7 @@ jobs:
 
   success-check-ml:
     name: Docker Build & Push ML Success
-    needs: [merge_ml, retag_ml]
+    needs: [machine-learning, retag_ml]
     permissions: {}
     runs-on: ubuntu-latest
     if: always()
diff --git a/.github/workflows/multi-runner-build.yml b/.github/workflows/multi-runner-build.yml
new file mode 100644
index 0000000000..17eceb7e8f
--- /dev/null
+++ b/.github/workflows/multi-runner-build.yml
@@ -0,0 +1,185 @@
+name: 'Multi-runner container image build'
+on:
+  workflow_call:
+    inputs:
+      image:
+        description: 'Name of the image'
+        type: string
+        required: true
+      context:
+        description: 'Path to build context'
+        type: string
+        required: true
+      dockerfile:
+        description: 'Path to Dockerfile'
+        type: string
+        required: true
+      tag-suffix:
+        description: 'Suffix to append to the image tag'
+        type: string
+        default: ''
+      dockerhub-push:
+        description: 'Push to Docker Hub'
+        type: boolean
+        default: false
+      build-args:
+        description: 'Docker build arguments'
+        type: string
+        required: false
+      platforms:
+        description: 'Platforms to build for'
+        type: string
+      runner-mapping:
+        description: 'Mapping from platforms to runners'
+        type: string
+    secrets:
+      DOCKERHUB_USERNAME:
+        required: false
+      DOCKERHUB_TOKEN:
+        required: false
+
+env:
+  GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ inputs.image }}
+  DOCKERHUB_IMAGE: altran1502/${{ inputs.image }}
+
+jobs:
+  matrix:
+    name: 'Generate matrix'
+    runs-on: ubuntu-latest
+    outputs:
+      matrix: ${{ steps.matrix.outputs.matrix }}
+      key: ${{ steps.artifact-key.outputs.base }}
+    steps:
+      - name: Generate build matrix
+        id: matrix
+        shell: bash
+        env:
+          PLATFORMS: ${{ inputs.platforms || 'linux/amd64,linux/arm64' }}
+          RUNNER_MAPPING: ${{ inputs.runner-mapping || '{"linux/amd64":"ubuntu-latest","linux/arm64":"ubuntu-24.04-arm"}' }}
+        run: |
+          matrix=$(jq -R -c \
+            --argjson runner_mapping "${RUNNER_MAPPING}" \
+            'split(",") | map({platform: ., runner: $runner_mapping[.]})' \
+            <<< "${PLATFORMS}")
+          echo "${matrix}"
+          echo "matrix=${matrix}" >> $GITHUB_OUTPUT
+
+      - name: Determine artifact key
+        id: artifact-key
+        shell: bash
+        env:
+          IMAGE: ${{ inputs.image }}
+          SUFFIX: ${{ inputs.tag-suffix }}
+        run: |
+          if [[ -n "${SUFFIX}" ]]; then
+              base="${IMAGE}${SUFFIX}-digests"
+          else
+              base="${IMAGE}-digests"
+          fi
+          echo "${base}"
+          echo "base=${base}" >> $GITHUB_OUTPUT
+
+  build:
+    needs: matrix
+    runs-on: ${{ matrix.runner }}
+    permissions:
+      contents: read
+      packages: write
+    strategy:
+      fail-fast: false
+      matrix:
+        include: ${{ fromJson(needs.matrix.outputs.matrix) }}
+    steps:
+      - name: Checkout
+        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+        with:
+          persist-credentials: false
+
+      - uses: ./.github/actions/image-build
+        with:
+          context: ${{ inputs.context }}
+          dockerfile: ${{ inputs.dockerfile }}
+          image: ${{ env.GHCR_IMAGE }}
+          ghcr-token: ${{ secrets.GITHUB_TOKEN }}
+          platform: ${{ matrix.platform }}
+          artifact-key-base: ${{ needs.matrix.outputs.key }}
+          build-args: ${{ inputs.build-args }}
+
+  merge:
+    needs: [matrix, build]
+    runs-on: ubuntu-latest
+    if: ${{ !github.event.pull_request.head.repo.fork }}
+    permissions:
+      contents: read
+      actions: read
+      packages: write
+    steps:
+      - name: Download digests
+        uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
+        with:
+          path: ${{ runner.temp }}/digests
+          pattern: ${{ needs.matrix.outputs.key }}-*
+          merge-multiple: true
+
+      - name: Login to Docker Hub
+        if: ${{ inputs.dockerhub-push }}
+        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+      - name: Login to GHCR
+        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3
+
+      - name: Generate docker image tags
+        id: meta
+        uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5
+        env:
+          DOCKER_METADATA_PR_HEAD_SHA: 'true'
+        with:
+          flavor: |
+            # Disable latest tag
+            latest=false
+            suffix=${{ inputs.tag-suffix }}
+          images: |
+            name=${{ env.GHCR_IMAGE }}
+            name=${{ env.DOCKERHUB_IMAGE }},enable=${{ inputs.dockerhub-push }}
+          tags: |
+            # Tag with branch name
+            type=ref,event=branch
+            # Tag with pr-number
+            type=ref,event=pr
+            # Tag with long commit sha hash
+            type=sha,format=long,prefix=commit-
+            # Tag with git tag on release
+            type=ref,event=tag
+            type=raw,value=release,enable=${{ github.event_name == 'release' }}
+
+      - name: Create manifest list and push
+        working-directory: ${{ runner.temp }}/digests
+        run: |
+          # Process annotations
+          declare -a ANNOTATIONS=()
+          if [[ -n "$DOCKER_METADATA_OUTPUT_JSON" ]]; then
+            while IFS= read -r annotation; do
+              # Extract key and value by removing the manifest: prefix
+              if [[ "$annotation" =~ ^manifest:(.+)=(.+)$ ]]; then
+                key="${BASH_REMATCH[1]}"
+                value="${BASH_REMATCH[2]}"
+                # Use array to properly handle arguments with spaces
+                ANNOTATIONS+=(--annotation "index:$key=$value")
+              fi
+            done < <(jq -r '.annotations[]' <<< "$DOCKER_METADATA_OUTPUT_JSON")
+          fi
+
+          TAGS=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+          SOURCE_ARGS=$(printf "${GHCR_IMAGE}@sha256:%s " *)
+
+          docker buildx imagetools create $TAGS "${ANNOTATIONS[@]}" $SOURCE_ARGS