Loading...
E.G.

Home-Assistant-Backups auf S3-kompatiblem Speicher ohne AWS

4. November 2026
Teil 3 von 3Serie: Umzug eines Hauses auf eine neue Home-Assistant-InstanzAlle Teile
  1. 01Home-Assistant-Umzug: alte und neue Instanz parallel
  2. 02Home Assistant: Packages statt Include-Dateien
  3. 03Home-Assistant-Backups auf S3-kompatiblem Speicher ohne AWS
Inhaltsverzeichnis

Während der Migration habe ich die Backup-Einstellungen der Home-Assistant-Instanz geöffnet, die ich gerade auf einer kleinen Hetzner-VM neu aufbaue, und null Backups gefunden. Die Instanz lief da seit Wochen und steuerte schon Teile des Hauses. Ich habe schon darüber geschrieben, was in ein Backup des ganzen Hosts gehört, und über den Neuaufbau nach einem Verlust. Diese beiden Posts behandeln, was gesichert wird und wie man es zurückspielt. Hier beschreibe ich, wie ich das Home-Assistant-Backup auf S3-kompatiblen Object Storage außerhalb von AWS kopiere und eine Meldung bekomme, sobald das letzte lokale Backup oder der letzte abgeschlossene Kopierlauf mehr als 30 Stunden zurückliegt.

Was gefehlt hat

In den Backup-Einstellungen stand automatic_backups_configured: false, und es gab keine Backups. Gesichert war nur die ganze VM, über Hetzner-Snapshots. In meinen Notizen zur Einrichtung dieser VM war ein Backup der Home-Assistant-Konfiguration als offener Punkt vermerkt, mit dem Hinweis, dass der VM-Snapshot es nicht ersetzt. Danach habe ich die Instanz schon für die Haussteuerung benutzt, obwohl dieses Backup noch fehlte. Die Lücke habe ich nur gefunden, weil mich die Migration dazu gebracht hat, die Einstellungsseite zu lesen.

Object Storage war schon vorhanden. Am selben Tag war ein Medienarchiv, das auf derselben Maschine läuft, von Azure Blob Storage zu Hetzner Object Storage umgezogen, weil sich der alte Upload über die Managed Identity einer Azure-VM angemeldet hat und eine Hetzner-VM keine hat. Der Bucket liegt im selben Projekt und am selben Standort wie die VM. Bucket und Zugangsschlüssel habe ich in der Hetzner-Konsole angelegt. Die hcloud-CLI verwaltet Storage Boxes, aber kein Object Storage, und mit einem S3-Werkzeug habe ich den Schritt nicht automatisiert.

Warum die eingebaute aws_s3-Integration andere Anbieter ablehnt

Home Assistant 2026.8 bringt eine Integration namens aws_s3 als Backup-Ziel mit, und ihr Einrichtungsformular hat ein Feld für die Endpoint-URL, das sich frei ausfüllen lässt. Das sah nach der naheliegenden Lösung aus. Im Config-Flow der Integration steht diese Prüfung, zitiert aus dem Quelltext: if not hostname.endswith("amazonaws.com"), und das Formular antwortet mit invalid_endpoint_url. Das Feld gibt es, und die Validierung dahinter schließt jeden anderen S3-kompatiblen Anbieter aus.

Ich bin deshalb eine Ebene tiefer gegangen und habe die Aufgabe geteilt. Home Assistant schreibt sein Backup auf die lokale Platte, was ohne jede Backup-Ziel-Integration geht, und ein kleines eigenes Skript übernimmt den Transport in den Bucket.

Der Aufbau: lokales verschlüsseltes Backup und ein getrennter Kopierjob

Home Assistant erstellt jetzt täglich um 03:30 ein verschlüsseltes automatisches Backup und behält lokal 7 Stück. Um 04:40 startet ein systemd-Timer auf dem Host das Kopierskript im Home-Assistant-Container. Dass es dort läuft, hat einen praktischen Grund: boto3 ist im offiziellen Home-Assistant-Container bereits enthalten, das Skript braucht also keine zusätzliche Abhängigkeit und keine zweite Python-Umgebung auf dem Host.

# ha-backup-s3.timer
# läuft nach dem automatischen Home-Assistant-Backup um 03:30
[Timer]
OnCalendar=*-*-* 04:40:00
Persistent=true
Unit=ha-backup-s3.service

# ha-backup-s3.service
# das Skript läuft im HA-Container, boto3 ist dort schon vorhanden
[Service]
Type=oneshot
ExecStart=/usr/bin/docker exec homeassistant python /config/tools/ha_backup_s3.py

Das Skript selbst löscht in der Cloud nichts. Eine Lifecycle-Regel im Bucket sorgt dafür, dass Hetzner die Home-Assistant-Backups und die Dateien des Archivs nach 180 Tagen löscht. Lokal räumt Home Assistant selbst auf und behält seine 7. Die Zugangsschlüssel liegen im Passwort-Manager und in einer Secrets-Datei auf der VM, und das Skript liest sie zur Laufzeit. Wie der Container an diese Datei kommt und wie der S3-Client eingerichtet ist, steht nicht im Ausschnitt unten.

Wie die Kopie geprüft wird

Die Prüfung ist dieselbe, die der Archivjob benutzt, bevor er eine Datei lokal löschen darf, und ich habe sie unverändert übernommen. Nach dem Upload liest das Skript die Metadaten des Objekts zurück und vergleicht Größe und ETag mit der lokalen Datei. Bei Hetzner ist der ETag nach einem einzelnen PUT der MD5 des Objekts. Andere Anbieter oder Verschlüsselungseinstellungen können sich anders verhalten, das sollte man vor einer Übernahme des Skripts prüfen. Außerdem prüft das Skript, ob der SHA-256, den es beim Upload mitgegeben hat, in den Metadaten steht. Diese letzte Prüfung bestätigt nur die Metadaten, der gespeicherte Inhalt wird erst im Restore-Test weiter unten neu gehasht. Nach erfolgreicher Prüfung legt das Skript eine Quittung für die Datei an und überspringt sie beim nächsten Lauf.

# Kern des Kopierjobs: hochladen, zurücklesen, vergleichen, dann die Quittung schreiben
for path in sorted(BACKUPS.glob("*.tar")):
    stat = path.stat()
    if receipts.get(path.name, {}).get("size") == stat.st_size:
        continue                          # schon kopiert und geprüft
    md5, sha = digests(path)              # ein Durchlauf über die Datei, beide Hashes
    key = f"ha-backups/{path.name}"
    with path.open("rb") as body:
        s3.put_object(Bucket=BUCKET, Key=key, Body=body,
                      ContentMD5=base64.b64encode(md5).decode(),
                      Metadata={"sha256": sha})
    head = s3.head_object(Bucket=BUCKET, Key=key)
    if (head["ContentLength"] != stat.st_size           # Größe
            or head["ETag"].strip('"') != md5.hex()     # ETag = MD5 bei einem einzelnen PUT
            or head["Metadata"].get("sha256") != sha):  # SHA-256 aus den Objekt-Metadaten
        raise RuntimeError(f"Verifikation fehlgeschlagen für {key}")
    receipts[path.name] = {"key": key, "size": stat.st_size, "sha256": sha}
    write_json(RECEIPTS, receipts)

Der Archivjob, der denselben Bucket benutzt, prüft außerdem vor jedem Lauf, ob eine anonyme Anfrage mit 403 abgewiesen wird. Das erste Home-Assistant-Backup war 163 MB groß und in 2 Sekunden hochgeladen, VM und Bucket liegen am selben Standort.

Den Verschlüsselungsschlüssel speichern, bevor er gesetzt wird

Ich habe den Verschlüsselungsschlüssel zuerst im Passwort-Manager gespeichert, ihn von dort noch einmal abgerufen und erst danach in Home Assistant eingetragen. Bei umgekehrter Reihenfolge gibt es einen Zeitraum, in dem Home Assistant schon Backups mit einem Schlüssel verschlüsselt, der an genau einer Stelle existiert.

Danach habe ich geprüft, ob sich das Backup überhaupt öffnen lässt. Home Assistant hat dafür einen Websocket-Befehl, backup/can_decrypt_on_download. Mit dem Schlüssel aus dem Passwort-Manager kam OK zurück, und mit einem absichtlich falschen Schlüssel wurde die Anfrage abgelehnt. Dieser Test bestätigt, dass der Schlüssel zum Backup passt. Acht Tage später habe ich die Kopie im Object Storage geprüft. Ein Skript lädt das neueste Backup herunter, vergleicht Größe und SHA-256 mit den Metadaten im Speicher und entschlüsselt und entpackt es mit dem Schlüssel aus dem Passwort-Manager. Das Backup war bis dahin auf 2,15 GB gewachsen. Der Download dauerte 9 Sekunden, das Entpacken von 3.012 Dateien 61 Sekunden. Alle 32 Dateien unter .storage waren als JSON lesbar, 23 von 23 Integrationen und 16 von 16 Package-Dateien stimmten mit der laufenden Instanz überein, und der SQLite-Quick-Check der Datenbank lief durch. Eine neue Home-Assistant-Instanz habe ich aus diesem Backup nicht gestartet. Der letzte Schritt einer echten Wiederherstellung ist also weiterhin ungetestet.

Überwachung von Backup und Kopie

Der Timer läuft auf dem Host und startet das Skript im Container, beides gehört also nicht zu Home Assistant selbst. Wenn der Timer nicht mehr auslöst oder das Skript jede Nacht scheitert, ändert sich in Home Assistant nichts, und die lokalen Backups entstehen weiter. Deshalb schreibt der Kopierjob eine kleine Statusdatei mit dem Zeitpunkt seines letzten abgeschlossenen Laufs, ein command_line-Sensor liest diese Datei, und ein Binärsensor sammelt die Gründe, warum etwas nicht stimmt: Home-Assistant-Backup älter als 30 Stunden, S3-Kopie älter als 30 Stunden, dazu die entsprechenden Prüfungen für den Archivjob. Steht der Sensor 15 Minuten auf an, schickt eine Automation eine Push-Nachricht mit der Liste der Gründe. Diese Automation steht nicht im YAML unten. Die Statusdatei hält fest, wann der Kopierjob zuletzt durchgelaufen ist, ein Lauf ohne neue Datei zählt also auch. Das Alter des neuesten Home-Assistant-Backups prüft die erste Bedingung getrennt.

# der Kopierjob schreibt eine Statusdatei, Home Assistant liest sie
command_line:
  - sensor:
      name: "Backup S3 Status"
      command: "cat /config/backups/.s3-status.json"
      value_template: "{{ value_json.last_run_utc }}"
      device_class: timestamp
      scan_interval: 600

template:
  # trigger-basiert: die Gründe werden einmal berechnet und für Zustand und Attribut benutzt
  - triggers:
      - trigger: time_pattern
        minutes: "/5"
      - trigger: state
        entity_id:
          - sensor.backup_s3_status
          - sensor.backup_last_successful_automatic_backup
    variables:
      gruende: >-
        {% set r = namespace(l=[]) %}
        {% set b = states('sensor.backup_last_successful_automatic_backup') | as_datetime %}
        {% if b is none or (now() - b).total_seconds() > 30 * 3600 %}
          {% set r.l = r.l + ['HA-Backup älter als 30 h'] %}
        {% endif %}
        {% set s = states('sensor.backup_s3_status') | as_datetime %}
        {% if s is none or (now() - s).total_seconds() > 30 * 3600 %}
          {% set r.l = r.l + ['Backup-Kopie nach S3 älter als 30 h'] %}
        {% endif %}
        {{ r.l }}
    binary_sensor:
      - name: "Datensicherung Problem"
        device_class: problem
        state: "{{ gruende | count > 0 }}"
        attributes:
          gruende: "{{ gruende }}"

Getestet habe ich das mit einer simulierten Uhr, indem ich das Template über die Template-API mit now() + timedelta(days=3) anstelle von now() ausgewertet habe. Alle Altersprüfungen schlugen an. Der Test hat außerdem eine Lücke in einer der Archivprüfungen gezeigt. Bei einer Quelle, die auf der neuen Instanz noch nie etwas archiviert hatte, fehlte der Zeitstempel des letzten erfolgreichen Laufs, und ihre 72-Stunden-Prüfung schlug deshalb nie an. Solche stillen Ausfälle soll der Sensor melden. Für eine solche Quelle läuft die 72-Stunden-Frist jetzt ab dem Start des Archivs. Mein erster Entwurf des Sensors hatte einen zweiten Fehler. Sein Zustand las über this.attributes das eigene vorherige Attribut und hinkte immer eine Aktualisierung hinterher. Die Fassung oben ist trigger-basiert und berechnet die Liste einmal in einer Variable.

Was bisher schiefging

Den bisher größten Ausfall meldete der Sensor am 8. Oktober: Die S3-Kopie war älter als 30 Stunden. Das Medienarchiv lag unter /config, und bei einer Container-Installation lassen sich Ordner unter /config nicht vom Home-Assistant-Backup ausnehmen. Das Archiv war auf 4,2 GB gewachsen, das Backup von 1,2 GB am 2. Oktober auf 4,5 GB, und die 38-GB-Platte war voll. Das Kopierskript brach bei dieser Datei ab, weil es mit einem einzelnen PUT nur bis 4 GB hochlädt, und auch neuere Backups wurden deshalb nicht mehr kopiert. Ich habe das Archiv in ein eigenes Volume außerhalb von /config verschoben, das nächste Backup hatte 68 MB. Das Skript überspringt eine zu große Datei jetzt, kopiert den Rest und meldet das (diese Größenprüfung fehlt im Python-Ausschnitt oben), und ein neuer Sensor warnt, wenn weniger als 6 GB frei sind. Der Problem-Sensor meldet jetzt auch übersprungene Dateien und zu wenig freien Speicher, diese beiden Prüfungen fehlen im YAML-Ausschnitt oben. Der oben beschriebene Restore-Test lief vor diesem Umbau, noch mit dem größeren Backup.

Beim Archivjob wurde der erste Upload eines Laufs zweimal mit AccessDenied abgelehnt. Dieselbe Datei ging im nächsten Lauf durch, und drei weitere Läufe zeigten keinen Fehler, ich konnte es also nicht reproduzieren und kenne die Ursache nicht. Die Schutzlogik hat wie vorgesehen gegriffen: Die Datei blieb auf der lokalen Platte und wurde eine Stunde später hochgeladen. Das Upload-Skript wiederholt fehlgeschlagene PUT- und HEAD-Anfragen jetzt und protokolliert den Fehlercode.

Vor dem ersten Core-Update auf der neuen Instanz habe ich mit dem Dienst backup.create_automatic ebenfalls eine frische verschlüsselte Sicherung ausgelöst und erst danach den Image-Tag geändert. Das Update- und Rollback-Verfahren für den Container hat einen eigenen Post.

Häufige Fragen

Lässt sich die eingebaute aws_s3-Backup-Integration mit einem S3-Anbieter außerhalb von AWS nutzen?

Mit Home Assistant 2026.8, dem Release, das bei mir lief, nicht. Der Config-Flow lehnt jeden Endpoint ab, dessen Hostname nicht auf amazonaws.com endet.

Wie lange werden die Backups aufbewahrt, und wer löscht sie?

Home Assistant behält lokal die letzten 7 automatischen Backups und entfernt ältere selbst. Im Bucket löscht eine Lifecycle-Regel die Kopien nach 180 Tagen.

Wie prüfe ich, ob sich ein verschlüsseltes Backup öffnen lässt, ohne es wiederherzustellen?

Der Websocket-Befehl backup/can_decrypt_on_download prüft, ob der Schlüssel zum Backup passt. Den Inhalt habe ich zusätzlich durch Herunterladen, Entschlüsseln und Entpacken geprüft, den Start einer neuen Instanz daraus aber nicht getestet.

Verwandte Artikel

Fragen oder Ergänzungen?

Die Kommentare laufen über GitHub Discussions, zum Schreiben brauchst du ein GitHub-Konto.