GitLab CI für ESPHome: Firmware automatisiert bauen, testen und deployen
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.
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.
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:
- validate – jede Device-Config wird geprüft (
esphome config) - build – Firmware wird tatsächlich kompiliert (Matrix-Build pro Device)
- 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:
- 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.
- 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.
- MDNS funktioniert im Runner nicht immer.
${DEVICE}.locallöst im Docker-Container nicht automatisch auf. Zwei Optionen: Entweder feste IPs pro Gerät in einer Lookup-Datei, oderavahi-daemonim Runner-Setup. Ich bin bei festen IPs gelandet – weniger Magic. - 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.