I run Immich in Docker inside an LXC container on my Proxmox host, and every new release meant opening a shell, jumping into the container and running docker compose by hand. I already had my Home Assistant dashboard telling me when a new Immich version was out, so the next step was obvious: an UPDATE button right on that card.

Getting the button to start the update was the easy part. Getting it to finish properly took a bit of debugging, because my button kept spinning on UPDATING forever. In this post I’ll share the complete setup and what I learned along the way.

How it fits together

  • A custom button-card on my Home Assistant dashboard shows the current and latest Immich version number.
  • The UPDATE button runs a Home Assistant script
  • The script calls a shell_command that connects to Proxmox over SSH
  • On Proxmox, update-immich.sh uses pct exec to run docker compose inside the Immich container (CT128)
  • When Immich is back up, immich-update-check.sh publishes the new versions over MQTT
  • Home Assistant picks up the MQTT sensors and the card switches to Up to date

Immich - Update available

Step 1: The update script on Proxmox

All my helper scripts live in /opt/scripts on the Proxmox host. The update script pulls the new image, restarts the container, cleans up old images and then refreshes the update status:


#!/bin/bash
set -e
echo "=== Immich update started: $(date) ==="
echo "Pulling latest Immich images..."
pct exec 128 -- bash -c 'cd /opt/immich && docker compose pull'

echo "Starting updated Immich containers..."
pct exec 128 -- bash -c 'cd /opt/immich && docker compose up -d'

echo "Removing unused Docker images..."
pct exec 128 -- bash -c 'docker image prune -f'

echo "Waiting for Immich to come back up..."
for i in $(seq 1 60); do
  if curl -fs http://192.168.1.28:2283/api/server/ping > /dev/null; then
    echo "Immich is up after $((i*5))s"
    break
  fi
  sleep 5
done

echo "Refreshing Immich update status..."
/opt/scripts/immich-update-check.sh

echo "=== Immich update completed: $(date) ==="

The wait loop is important. Without it, the update check runs right after docker compose up -d, while the Immich server is still starting. The check then can’t read the new version and the status never flips to Up to date. Now the script polls the Immich ping endpoint every 5 seconds (for up to 5 minutes) before it refreshes the status.

Immich - Scripts list

Step 2: The update check script

immich-update-check.sh compares the running Immich version with the latest release and publishes three values over MQTT, which become these sensors in Home Assistant:

  • sensor.mqtt_immich_current_version
  • sensor.mqtt_immich_latest_version
  • sensor.mqtt_immich_update_available

Immich - MQTT values


#!/bin/bash
MQTT_HOST="192.168.1.9"
MQTT_TOPIC_BASE="immich/update"
IMMICH_URL="http://192.168.1.28:2283"

CURRENT=$(curl -s "${IMMICH_URL}/api/server/version" \
  | sed -n 's/.*"major":\([0-9]*\).*"minor":\([0-9]*\).*"patch":\([0-9]*\).*/v\1.\2.\3/p')

LATEST=$(curl -s https://api.github.com/repos/immich-app/immich/releases/latest \
  | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')

if [[ -z "$CURRENT" || -z "$LATEST" ]]; then
    echo "$(date -Is) Failed to retrieve version info (current=$CURRENT latest=$LATEST)"
    exit 1
fi

if [[ "$CURRENT" != "$LATEST" ]]; then
    UPDATE_AVAILABLE="true"
else
    UPDATE_AVAILABLE="false"
fi

mosquitto_pub -h "$MQTT_HOST" -t "${MQTT_TOPIC_BASE}/available" -m "$UPDATE_AVAILABLE" -r
mosquitto_pub -h "$MQTT_HOST" -t "${MQTT_TOPIC_BASE}/current_version" -m "$CURRENT" -r
mosquitto_pub -h "$MQTT_HOST" -t "${MQTT_TOPIC_BASE}/latest_version" -m "$LATEST" -r

echo "$(date -Is) Current: $CURRENT | Latest: $LATEST | Update available: $UPDATE_AVAILABLE"

Step 3: SSH from Home Assistant to Proxmox

Home Assistant needs its own SSH key, stored under /config so it survives restarts. From the Home Assistant terminal (192.168.1.14 is the IP address of the Proxmox host):


mkdir -p /config/.ssh
ssh-keygen -t ed25519 -f /config/.ssh/id_ed25519_proxmox -N ""
ssh-copy-id -i /config/.ssh/id_ed25519_proxmox.pub root@192.168.1.14

Then the shell_command itself:


# config_shell_command.yaml
proxmox_update_immich: >-
  ssh -i /config/.ssh/id_ed25519_proxmox
  -o UserKnownHostsFile=/config/.ssh/known_hosts
  -o StrictHostKeyChecking=accept-new
  -o BatchMode=yes
  -o ConnectTimeout=10
  root@192.168.1.14
  "nohup /opt/scripts/update-immich.sh > /var/log/update-immich.log 2>&1 < /dev/null &"

Two things matter here. First, BatchMode=yes and ConnectTimeout=10 make SSH fail fast instead of hanging on a prompt. Second, Home Assistant kills a shell_command after 60 seconds, and pulling new Immich images takes longer than that. So the command starts the update in the background with nohup, redirects all output to a log file and returns immediately. The redirects and < /dev/null are needed, otherwise SSH keeps waiting for the background process anyway.

Step 4: The Home Assistant script

First I created a toggle helper, input_boolean.immich_update_running, which the card uses to show the UPDATING state.

Immich Helper

Then the Home Assistant script:


proxmox_update_immich:
  alias: Proxmox - Update Immich
  description: Runs the Immich update on Proxmox and tracks update progress
  mode: single
  sequence:
    - condition: state
      entity_id: sensor.mqtt_immich_update_available
      state: "true"
    - action: input_boolean.turn_on
      target:
        entity_id: input_boolean.immich_update_running
    - action: shell_command.proxmox_update_immich
      response_variable: ssh_result
    - if:
        - condition: template
          value_template: "{{ ssh_result.returncode != 0 }}"
      then:
        - action: input_boolean.turn_off
          target:
            entity_id: input_boolean.immich_update_running
        - stop: "SSH to Proxmox failed"
          error: true
    - wait_template: "{{ states('sensor.mqtt_immich_update_available') | lower == 'false' }}"
      timeout: "00:20:00"
      continue_on_timeout: true
    - if:
        - condition: template
          value_template: "{{ not wait.completed }}"
      then:
        - action: persistent_notification.create
          data:
            title: Immich update
            message: "No 'up to date' status after 20 min. Check /var/log/update-immich.log on Proxmox."
    - action: input_boolean.turn_off
      target:
        entity_id: input_boolean.immich_update_running

The script turns the boolean on, starts the update over SSH and checks the SSH return code. If SSH fails, it resets the boolean right away. Otherwise it waits until the update sensor reports false, with a 20-minute timeout. Whatever happens, the last step always turns the boolean off again, and if the update didn’t finish in time I get a notification pointing me to the log.

Step 5: The dashboard card

The card is a custom:button-card (HACS) with a nested button-card for the update button. The button only does something when an update is available and no update is running, it asks for confirmation first, and the icon spins while the update runs. If you’re not interested in reading release notes first, you can use an automation instead that triggers the update script when mqtt update=true is received.


type: custom:button-card
entity: sensor.mqtt_immich_update_available
name: Immich
icon: mdi:image-multiple
show_state: false
show_label: false
show_icon: true
show_name: true
triggers_update:
  - sensor.mqtt_immich_update_available
  - sensor.mqtt_immich_current_version
  - sensor.mqtt_immich_latest_version
  - input_boolean.immich_update_running
tap_action:
  action: none
styles:
  card:
    - border-radius: 16px
    - padding: 18px 20px
    - background: transparent
    - border: 2px solid var(--info-color)
    - box-shadow: 0 2px 8px rgba(0,0,0,0.20)
    - overflow: hidden
  grid:
    - grid-template-areas: |
        "i n status"
        "i versions update"
        "i release update"
    - grid-template-columns: 42px 1fr auto
    - grid-template-rows: 30px 28px 28px
    - column-gap: 12px
  icon:
    - width: 28px
    - color: var(--primary-text-color)
  name:
    - justify-self: start
    - align-self: center
    - font-size: 17px
    - font-weight: 600
    - color: var(--primary-text-color)
  custom_fields:
    status:
      - justify-self: end
      - align-self: center
    versions:
      - justify-self: start
      - align-self: center
    release:
      - justify-self: start
      - align-self: center
    update:
      - justify-self: end
      - align-self: center
custom_fields:
  status: |
    [[[
      const running =
        states['input_boolean.immich_update_running']?.state === 'on';

      const available =
        entity.state === 'true' ||
        entity.state === 'on' ||
        entity.state === '1';

      if (running) {
        return `
          <span style="
            color:#ffb74d;
            font-size:13px;
            font-weight:600;
          ">
            ● Updating
          </span>
        `;
      }

      if (available) {
        return `
          <span style="
            color:#ffb74d;
            font-size:13px;
            font-weight:600;
          ">
            ● Update available
          </span>
        `;
      }

      return `
        <span style="
          color:#66bb6a;
          font-size:13px;
          font-weight:600;
        ">
          ● Up to date
        </span>
      `;
    ]]]
  versions: |
    [[[
      const current =
        states['sensor.mqtt_immich_current_version']?.state ?? 'Unknown';

      const latest =
        states['sensor.mqtt_immich_latest_version']?.state ?? 'Unknown';

      return `
        <div style="
          display:flex;
          align-items:center;
          gap:8px;
          font-size:14px;
          white-space:nowrap;
        ">
          <span style="color:var(--secondary-text-color);">
            Current
          </span>

          <strong style="color:var(--primary-text-color);">
            ${current}
          </strong>

          <span style="color:var(--secondary-text-color);">
            →
          </span>

          <span style="color:var(--secondary-text-color);">
            Latest
          </span>

          <strong style="color:var(--primary-text-color);">
            ${latest}
          </strong>
        </div>
      `;
    ]]]
  release:
    card:
      type: custom:button-card
      name: Release notes ↗
      show_icon: false
      show_state: false
      tap_action:
        action: url
        url_path: https://github.com/immich-app/immich/releases
      styles:
        card:
          - background: transparent
          - border: none
          - box-shadow: none
          - padding: 0
          - margin: 0
          - min-height: 0
        grid:
          - grid-template-areas: '"n"'
          - grid-template-columns: auto
        name:
          - justify-self: start
          - font-size: 13px
          - font-weight: 600
          - color: var(--primary-color)
          - text-decoration: underline
  update:
    card:
      type: custom:button-card
      entity: sensor.mqtt_immich_update_available
      show_name: true
      show_icon: true
      show_state: false
      triggers_update:
        - sensor.mqtt_immich_update_available
        - input_boolean.immich_update_running
      icon: |
        [[[
          const running =
            states['input_boolean.immich_update_running']?.state === 'on';

          return running
            ? 'mdi:loading'
            : 'mdi:update';
        ]]]
      name: |
        [[[
          const running =
            states['input_boolean.immich_update_running']?.state === 'on';

          const available =
            entity.state === 'true' ||
            entity.state === 'on' ||
            entity.state === '1';

          if (running)
            return 'UPDATING';

          return available
            ? 'UPDATE'
            : 'UP TO DATE';
        ]]]
      tap_action:
        action: |
          [[[
            const running =
              states['input_boolean.immich_update_running']?.state === 'on';

            const available =
              entity.state === 'true' ||
              entity.state === 'on' ||
              entity.state === '1';

            return (!running && available)
              ? 'perform-action'
              : 'none';
          ]]]
        perform_action: script.proxmox_update_immich
        confirmation:
          text: >-
            Update Immich now?

            This will download the latest Immich images and restart the Immich
            containers.
      styles:
        card:
          - border-radius: 10px
          - padding: 9px 16px
          - box-shadow: none
          - border: none
          - background: |
              [[[
                const running =
                  states['input_boolean.immich_update_running']?.state === 'on';

                const available =
                  entity.state === 'true' ||
                  entity.state === 'on' ||
                  entity.state === '1';

                if (running)
                  return 'rgba(255,183,77,0.18)';

                return available
                  ? 'var(--primary-color)'
                  : 'rgba(255,255,255,0.06)';
              ]]]
          - cursor: |
              [[[
                const running =
                  states['input_boolean.immich_update_running']?.state === 'on';

                const available =
                  entity.state === 'true' ||
                  entity.state === 'on' ||
                  entity.state === '1';

                return (!running && available)
                  ? 'pointer'
                  : 'default';
              ]]]
        grid:
          - grid-template-areas: '"i n"'
          - grid-template-columns: 18px auto
          - column-gap: 7px
        icon:
          - width: 17px
          - color: |
              [[[
                const running =
                  states['input_boolean.immich_update_running']?.state === 'on';

                return running
                  ? '#ffb74d'
                  : 'white';
              ]]]
          - animation: |
              [[[
                const running =
                  states['input_boolean.immich_update_running']?.state === 'on';

                return running
                  ? 'immich-spin 1s linear infinite'
                  : 'none';
              ]]]
        name:
          - font-size: 13px
          - font-weight: 700
          - color: |
              [[[
                const running =
                  states['input_boolean.immich_update_running']?.state === 'on';

                const available =
                  entity.state === 'true' ||
                  entity.state === 'on' ||
                  entity.state === '1';

                if (running)
                  return '#ffb74d';

                return available
                  ? 'white'
                  : 'var(--secondary-text-color)';
              ]]]
      extra_styles: |
        @keyframes immich-spin {
          from {
            transform: rotate(0deg);
          }
          to {
            transform: rotate(360deg);
          }
        }


Immich - Update start

Immich - Updating

The bug: a button stuck on UPDATING

My first version worked fine when I ran the script directly on Proxmox, but from the dashboard the button switched to UPDATING and never came back.

My first suspect was the 60-second shell_command limit, but my command was already fire-and-forget. The Home Assistant trace showed the real problem: the script was sitting on the wait_template. That wait had no timeout, and nothing after it turned the boolean off. Because the update check ran before Immich had finished starting, the update sensor stayed true, so the script waited forever.

The fix was the combination you see above: the wait loop in the update script, and a timeout plus a guaranteed reset in the Home Assistant script. My lessons:

  • Always give a wait_template a timeout
  • Make the final cleanup step run no matter what
  • Log the remote script to a file, so you can see whether it actually ran
  • Wait for a service to be healthy before checking its version

Wrapping up

Immich updates are now one tap on my dashboard, with live status and a log on Proxmox if something goes wrong. The same pattern works for any Docker app in a Proxmox LXC, so Frigate and UniFi are next on my list.

Privacy Preference Center