uptime · 1404 days · 23 posts published · last deploy 1 day, 10 hours ago build:passing rss
~ / home-automation / gitlab-ci-for-esphome-build-test-deploy-firmware.md
Home Automation · 21. April 2026 · ~13min · 0b7fbce

GitLab CI for ESPHome: Automatically Build, Test, and Deploy Firmware

From manual esphome run commands to a clean CI/CD pipeline for the smart home

>
devmaker.net
author · 0b7fbce · 2026-04-21
x
a-dark-themed-technical-illustration-showing-a-g.jpg 1024×1024
a-dark-themed-technical-illustration-showing-a-g
Image generated with AI
ESPHome configs control heating, sensors, and ventilation – too critical to push into the production system without a pipeline. This article presents a pragmatic GitLab CI setup that validates every config change, builds firmware for all devices in parallel, and only rolls out OTA updates on main. Including secrets management via GitLab variables, matrix builds for any number of devices, and a breaking change detector that warns before ESPHome major updates. No overkill, but enough safety for a production smart home.

# Why use CI/CD for ESPHome at all?

ESPHome configs are code. And code that runs in production – in my case, my configs control heating, ventilation, particulate matter sensors, and a few other things I want to be able to rely on – belongs in a proper pipeline. Yet for most people, day-to-day reality looks like this:

  • Adjust config in the editor
  • **Local esphome run device.yaml**
  • Waiting until the build is done
  • "Hoping that the OTA update works"
  • Here are a few translation options depending on the context: **General:** "With multiple devices, everything manually one after another" **More natural phrasing:** "With multiple devices, everything has to be done manually one at a time"

Here is the translation: That works – until you have 15 devices, until the config lives in a Git repo that you also want to work on from other machines, or until you accidentally miss a breaking change in ESPHome and half your apartment stops responding.

With a GitLab CI pipeline, this can be cleanly automated: every push validates all configs, builds the firmware images, and on main the updates are rolled out via OTA. In this article, I'll show my current setup – deliberately pragmatic, not over-engineered.

The repository layout

My ESPHome configs are all in a monorepo:

esphome-configs/
├── devices/
│   ├── particulate_matter_living_room.yaml
│   ├── heating_basement.yaml
│   ├── ventilation_bathroom.yaml
│   └── ...
├── common/
│   ├── base.yaml
│   ├── wifi.yaml
│   └── ota.yaml
├── secrets.yaml.example
├── .gitlab-ci.yml
└── README.md

The common/ snippets are included in the device configs via `<<: !include`. This way, WLAN credentials, OTA passwords, and basic settings only need to be maintained in one place.

Please provide the text you would like me to translate. So far I can only see the word: **"Wichtig"** = **"Important"** Please share the full text you'd like me to translate.

The real secrets.yaml does not end up in the Git repo (.gitignore), but is generated in the CI from GitLab variables.

The GitLab CI Pipeline

The pipeline has three stages:

  1. validate – every device config is checked (esphome config)
  2. build – Firmware is actually compiled (matrix build per device)
  3. deploy – OTA update to the real devices (only on main)

Here is the complete .gitlab-ci.yml:

Here is the translated text:

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:
          - particulate_matter_living_room
          - heating_basement
          - ventilation_bathroom
 
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

---

**Note:** Only the German device names were translated into English, as all other parts of the text are technical code/configuration syntax that should remain unchanged:

- `feinstaub_wohnzimmer` → `particulate_matter_living_room`
- `heizung_keller` → `heating_basement`
- `lueftung_bad` → `ventilation_bathroom`

Here is the translation: A few things I learned from this:

"Secrets handed over cleanly"

In GitLab under Settings → CI/CD → Variables, create a variable named ESPHOME_SECRETS, type File, masked and protected – depending on the branch setup. The content is the complete YAML content of the secrets.yaml:

wifi_ssid: "MyWiFi"
wifi_password: "supersecret"
ota_password: "also_secret"
api_encryption_key: "base64stuffhere..."

Here is the translation: The before_script hook writes the file in the job container. Clean separation, no credentials in the repo.

Matrix-Builds instead of endless jobs

Here is the translation: The parallel:matrix trick is worth its weight in gold. Instead of defining a separate job for each device, the whole thing runs parameterized. New device? Simply add the config entry in the matrix – done.

Caching for Build Performance

Here is the translation: Compiling ESPHome firmware can take anywhere from 5 to 10 minutes per device the first time, because PlatformIO pulls the toolchain and all dependencies. With GitLab caching, this can be massively accelerated:

Here is the translation of the text into English:

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

**Note:** This text is a CI/CD pipeline configuration (likely GitLab CI), written in YAML format. It does not require translation as it consists of technical code, configuration keys, and commands that are language-independent. The meaning and functionality remain identical in both the "original" and "translated" versions.

If you have any accompanying descriptive text or comments that need to be translated, please provide them and I will be happy to assist.

Second build of the same device: ~30 seconds instead of 8 minutes.

# OTA Deployment: Key Lessons Learned

Here is the translation: The deployment stage is intentionally set to `when: manual` in my case. Why? Because a failed OTA update, in the worst case, means I have to go down to the basement and flash the device via USB. I don't want that to happen automatically with every merge to main.

My rules:

  1. Staging device first. I have a test ESP on my desk that the pipeline deploys to automatically. Only when that runs cleanly do I manually trigger the production deployments.
  2. OTA requires network access. The GitLab Runner must be able to reach the devices. In my case, a self-hosted Runner is running on my homelab server in the same VLAN as the smarthome devices. Cloud GitLab Runners cannot do this – unless you expose OTA to the outside, which is a very bad idea.
  3. MDNS doesn't always work in the runner. ${DEVICE}.local doesn't resolve automatically inside the Docker container. Two options: either fixed IPs per device in a lookup file, or avahi-daemon in the runner setup. I went with fixed IPs – less magic.
  4. Document serial fallback. Just in case: Every device README states which GPIOs are responsible for USB flashing. It has already saved me twice.

# Quality Gates That Paid Off

Here is the translation: Beyond pure validation, I have also built in two checks that catch small problems early:

Check 1: YAML-Lint (This term remains unchanged as it is a technical term/tool name that is universally used in English and does not require translation.)

Here is the translation of the text into English:

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

**Note:** This text is a code snippet (likely a CI/CD pipeline configuration, such as GitLab CI), and therefore does not require translation, as code is universally written in English/technical syntax. The meaning and structure remain identical in both the original and the "translated" version.

If you meant to translate a **description** of this code, please provide the accompanying text, and I will be happy to translate it for you.

Catches tab indentations, faulty quoting situations, and other YAML pitfalls before the actual ESPHome parsing.

Check 2: Breaking Change Detector

Here is the translation: ESPHome regularly has deprecations. In the CI, I compare the current version with a .esphome-version file in the repo and let a job fail if the major version has changed:

Here is the translation of the text into English:

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 has changed – check the changelog!" && exit 1)
  allow_failure: true

---

**Note:** Only the German text within the echo message was translated:

- `"ESPHome-Version hat sich geändert – Changelog checken!"` → `"ESPHome version has changed – check the changelog!"`

All other parts of the code (YAML structure, commands, variables) were left unchanged, as they are technical code and not natural language.

Here is the translation: allow_failure: true, so that the pipeline still runs, but I get a big yellow warning. It has saved me from a broken night multiple times already.

"What I deliberately left out" or alternatively: "What I consciously omitted"

Here is the translation: There are a few things that are often seen in comparable setups, but that I don't do:

  • No hardware-in-the-loop tests. Sounds nice, but is overkill for a hobby smart home. The real validation happens on the staging device.
  • No automatic rollbacks. ESPHome does not support dual-partition OTA with automatic fallback reliably enough to automate this cleanly. If a device stops reporting back after an update, I receive an alert via Home Assistant – that's sufficient.
  • No container registry for firmwares. The build artifacts are stored in GitLab for one week – that is sufficient for rollbacks.

Conclusion

Here is the translation: The setup has been running for a few months now and has paid off. The most important gain is not the automation itself, but the confidence: when I change a config, I know that the pipeline validates it against all devices before anything goes live. No more surprised "why isn't the light working anymore" moments.

Here is the translation: If you've been running ESPHome locally via `esphome run` so far and your repo is already hosted at least on GitLab (or GitHub with Actions, which works analogously): an afternoon of setup time that pays off in the long run.

In the next article, I will show how I adapted the same pipeline for Tasmota devices – slightly trickier, because the build process works differently, but based on the same fundamental principle.

// responses (0)
> echo "your thoughts" >> gitlab-ci-for-esphome-build-test-deploy-firmware.responses

Post your comment

Required for comment verification