GitLab CI für ESPHome: Firmware automatisiert bauen, testen und deployen

21. April 2026

Von manuellen esphome run Kommandos zu einer sauberen CI/CD-Pipeline für das Smarthome

Wie man ESPHome-Firmware in einer GitLab-CI-Pipeline automatisch baut, validiert und OTA an die Geräte ausrollt – inklusive Secrets-Handling, Matrix-Builds für mehrere Devices und Quality Gates.

a-dark-themed-technical-illustration-showing-a-g

Bild generiert mit KI

Warum überhaupt CI/CD für ESPHome?

ESPHome-Configs sind Code. Und Code, der produktiv läuft – in meinem Fall steuern meine Configs Heizung, Lüftung, Feinstaubsensoren und ein paar andere Dinge, auf die ich mich verlassen will – gehört in eine ordentliche Pipeline. Trotzdem sieht der Alltag bei den meisten Leuten so aus:

  • Config im Editor anpassen
  • Lokal esphome run device.yaml
  • Warten, bis der Build durch ist
  • Hoffen, dass das OTA-Update klappt
  • Bei mehreren Geräten alles manuell nacheinander

Das funktioniert – bis man 15 Geräte hat, bis die Config in einem Git-Repo liegt, an dem man auch von anderen Rechnern arbeiten möchte, oder bis man mal eben einen Breaking Change in ESPHome übersieht und die halbe Wohnung nicht mehr spricht.

Mit einer GitLab-CI-Pipeline lässt sich das sauber automatisieren: Jeder Push validiert alle Configs, baut die Firmware-Images, und auf main werden die Updates per OTA ausgerollt. In diesem Artikel zeige ich mein aktuelles Setup – bewusst pragmatisch, nicht überengineered.

Das Repo-Layout

Meine ESPHome-Configs liegen alle in einem Monorepo:

esphome-configs/
├── devices/
│   ├── feinstaub_wohnzimmer.yaml
│   ├── heizung_keller.yaml
│   ├── lueftung_bad.yaml
│   └── ...
├── common/
│   ├── base.yaml
│   ├── wifi.yaml
│   └── ota.yaml
├── secrets.yaml.example
├── .gitlab-ci.yml
└── README.md

Die common/-Snippets werden per <<: !include in die Device-Configs eingebunden. So müssen WLAN-Credentials, OTA-Passwörter und Basis-Settings nur an einer Stelle gepflegt werden.

Wichtig

Die echte secrets.yaml landet nicht im Git-Repo (.gitignore), sondern wird in der CI aus GitLab-Variablen generiert.

Die GitLab-CI-Pipeline

Die Pipeline hat drei Stages:

  1. validate – jede Device-Config wird geprüft (esphome config)
  2. build – Firmware wird tatsächlich kompiliert (Matrix-Build pro Device)
  3. deploy – OTA-Update auf die echten Geräte (nur auf main)

Hier das komplette .gitlab-ci.yml:

stages:
  - validate
  - build
  - deploy

default:
  image: ghcr.io/esphome/esphome:2025.10.0
  before_script:
    - echo "$ESPHOME_SECRETS" > secrets.yaml

.device_matrix: &device_matrix
  parallel:
    matrix:
      - DEVICE:
          - feinstaub_wohnzimmer
          - heizung_keller
          - lueftung_bad

validate:
  stage: validate
  <<: *device_matrix
  script:
    - esphome config devices/${DEVICE}.yaml

build:
  stage: build
  <<: *device_matrix
  script:
    - esphome compile devices/${DEVICE}.yaml
  artifacts:
    paths:
      - .esphome/build/${DEVICE}/.pioenvs/${DEVICE}/firmware.bin
    expire_in: 1 week

deploy:
  stage: deploy
  <<: *device_matrix
  script:
    - esphome upload devices/${DEVICE}.yaml --device ${DEVICE}.local
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  when: manual

Ein paar Dinge, die ich dabei gelernt habe:

Secrets sauber übergeben

In GitLab unter Settings → CI/CD → Variables eine Variable ESPHOME_SECRETS anlegen, Type File, masked und protected – je nach Branch-Setup. Inhalt ist der komplette YAML-Inhalt der secrets.yaml:

wifi_ssid: "MeinWLAN"
wifi_password: "supergeheim"
ota_password: "auch_geheim"
api_encryption_key: "base64kramhier..."

Der before_script-Hook schreibt die Datei im Job-Container aus. Saubere Trennung, keine Credentials im Repo.

Matrix-Builds statt endloser Jobs

Der parallel:matrix-Trick ist Gold wert. Statt für jedes Device einen eigenen Job zu definieren, läuft das Ganze parametrisiert. Neues Gerät? Einfach den Config-Eintrag in der Matrix ergänzen – fertig.

Caching für Build-Performance

Das Kompilieren von ESPHome-Firmware dauert beim ersten Mal gerne mal 5-10 Minuten pro Device, weil PlatformIO die Toolchain und alle Abhängigkeiten zieht. Mit GitLab-Caching lässt sich das massiv beschleunigen:

build:
  stage: build
  <<: *device_matrix
  cache:
    key: esphome-${DEVICE}
    paths:
      - .esphome/
      - ~/.platformio/
  script:
    - esphome compile devices/${DEVICE}.yaml

Zweiter Build desselben Device: ~30 Sekunden statt 8 Minuten.

OTA-Deployment: wichtige Lessons Learned

Das Deployment-Stage ist bei mir bewusst when: manual. Warum? Weil ein fehlgeschlagenes OTA-Update im schlimmsten Fall bedeutet, dass ich in den Keller muss und das Gerät per USB flashe. Das will ich nicht automatisch bei jedem Merge auf main haben.

Meine Regeln:

  1. Staging-Device zuerst. Ich habe ein Test-ESP auf dem Schreibtisch, auf das die Pipeline automatisch deployt. Nur wenn das sauber läuft, klicke ich die Produktiv-Deployments manuell durch.
  2. OTA braucht Netzwerk-Zugang. Der GitLab-Runner muss die Geräte erreichen können. Bei mir läuft ein self-hosted Runner auf meinem Homelab-Server im gleichen VLAN wie die Smarthome-Devices. Cloud-GitLab-Runner können das nicht – es sei denn, man exposed OTA nach außen, was eine sehr schlechte Idee ist.
  3. MDNS funktioniert im Runner nicht immer. ${DEVICE}.local löst im Docker-Container nicht automatisch auf. Zwei Optionen: Entweder feste IPs pro Gerät in einer Lookup-Datei, oder avahi-daemon im Runner-Setup. Ich bin bei festen IPs gelandet – weniger Magic.
  4. Serial-Fallback dokumentieren. Für den Fall der Fälle: In jedem Device-README steht, welche GPIOs für USB-Flashing zuständig sind. Hat mich schon zweimal gerettet.

Quality Gates, die sich gelohnt haben

Über die reine Validation hinaus habe ich noch zwei Checks eingebaut, die kleine Probleme früh fangen:

Check 1: YAML-Lint

yamllint:
  stage: validate
  image: cytopia/yamllint:latest
  script:
    - yamllint -c .yamllint devices/ common/

Fängt Tab-Einrückungen, fehlerhafte Quoting-Situationen und andere YAML-Fallen vor dem eigentlichen ESPHome-Parse ab.

Check 2: Breaking-Change-Detektor

ESPHome hat regelmäßig Deprecations. Ich vergleiche in der CI die aktuelle Version mit einer .esphome-version-Datei im Repo und lasse einen Job fehlschlagen, wenn sich die Major-Version geändert hat:

version_check:
  stage: validate
  script:
    - EXPECTED=$(cat .esphome-version)
    - ACTUAL=$(esphome version | awk '{print $2}')
    - echo "Expected $EXPECTED, got $ACTUAL"
    - test "$EXPECTED" = "$ACTUAL" || (echo "ESPHome-Version hat sich geändert – Changelog checken!" && exit 1)
  allow_failure: true

allow_failure: true, damit die Pipeline trotzdem läuft, aber ich einen dicken gelben Hinweis bekomme. Hat mich schon mehrfach vor einer kaputten Nacht bewahrt.

Was ich bewusst weggelassen habe

Es gibt ein paar Dinge, die in vergleichbaren Setups oft zu sehen sind, die ich aber nicht mache:

  • Keine Hardware-in-the-Loop-Tests. Klingt schön, ist für ein Hobby-Smarthome aber Overkill. Die wirkliche Validierung passiert beim Staging-Device.
  • Keine automatischen Rollbacks. ESPHome unterstützt keine Dual-Partition-OTA mit automatischem Fallback zuverlässig genug, um das sauber zu automatisieren. Wenn ein Device nach Update nicht mehr zurückmeldet, bekomme ich per Home Assistant einen Alert – das reicht.
  • Keine Container-Registry für Firmwares. Die Build-Artefakte liegen in GitLab für eine Woche – das genügt für Rollbacks.

Fazit

Das Setup ist seit ein paar Monaten in Betrieb und hat sich bezahlt gemacht. Der wichtigste Gewinn ist nicht die Automation an sich, sondern das Vertrauen: Wenn ich eine Config ändere, weiß ich, dass die Pipeline sie gegen alle Devices validiert, bevor irgendwas produktiv geht. Keine überraschten "warum geht das Licht nicht mehr"-Momente mehr.

Wenn du ESPHome bisher nur lokal per esphome run gefahren hast und dein Repo schon mindestens auf GitLab (oder GitHub mit Actions, geht analog) liegt: ein Nachmittag Setup-Zeit, der sich langfristig rechnet.

Im nächsten Artikel zeige ich, wie ich die gleiche Pipeline für Tasmota-Devices adaptiert habe – etwas trickier, weil der Build-Prozess anders läuft, aber mit demselben Grundprinzip.

Teilen:


Schreibe einen Kommentar

Wird für die Bestätigung benötigt