DevOps

CI/CD for Embedded: Dockerized Build-Test-Deploy at Scale

Amine El Omari

Web developers have had CI/CD figured out for a decade. Push to main, a pipeline builds your container, runs your tests, and deploys to production. Embedded engineers? We're still arguing about whether the build machine should have GCC 9 or GCC 11, and the "CI pipeline" is a cron job that runs make all and emails the output to three people.

Here's how I've been fixing that across multiple projects — from BMS validation to rail systems to my own products.

Why Embedded CI/CD Is Different

Embedded CI/CD isn't just compiling code. A proper pipeline needs to handle:

The Architecture

After implementing variations of this across three companies, I've landed on a pattern that works. The core idea: everything runs in Docker, and the only thing that changes between CI providers is the runner config.

Stage 1: Build

A Dockerfile based on a minimal Linux image with the ARM GCC toolchain, CMake, and any vendor-specific SDK components. The build stage produces:

The Docker image itself is versioned and stored in a registry. This eliminates the "works on my machine" problem permanently.

Stage 2: Unit & Integration Tests

These run inside the same container. I use either GoogleTest (for C/C++ firmware) or pytest (for Python test frameworks). The key is that these tests don't need hardware — they test the software logic, protocol parsing, state machines, and error handling.

# GitHub Actions example
- name: Run Unit Tests
  run: |
    docker run --rm \
      -v $PWD:/workspace \
      build-env:latest \
      bash -c "cd /workspace/build && ctest --output-junit results.xml"
        

The --output-junit flag is critical. JUnit XML is the lingua franca of CI systems — GitHub Actions, GitLab CI, Jenkins, and Azure DevOps all understand it. This is also what Bud consumes for test reporting.

Stage 3: HiL/SiL Tests (Hardware-in-the-Loop)

This is where embedded CI diverges from web CI. You can't run HiL tests in a container — you need a physical test bench. The solution is a self-hosted runner connected to the test bench network.

  1. Container build completes in the cloud (stages 1-2)
  2. Artifacts are uploaded to shared storage (S3, Artifactory, or an SMB share)
  3. A self-hosted runner on the test bench network picks up the job
  4. The runner flashes the firmware to the target hardware
  5. Automated HiL tests execute via Bud Runner or a custom Python harness
  6. Results are published back to the CI dashboard as JUnit XML
Pro tip: always include a "bench health check" step before HiL tests. Power cycle the hardware, verify CAN bus connectivity, check power supply rails. You'd be surprised how many "test failures" are actually a loose cable or a bench that someone forgot to power on.

Stage 4: Reporting & Deployment

If all tests pass, the pipeline:

Jenkins vs. GitLab CI vs. GitHub Actions

I've implemented this pattern on all three. My take:

The One Thing That Matters Most

Tooling doesn't matter if the team doesn't trust the pipeline. I've seen teams invest months in CI/CD only to have engineers run tests manually because "the pipeline sometimes gives false positives." The pipeline must be reliable before it's fast. Start with a simple build+unit-test pipeline that never fails spuriously. Add HiL stages only when the foundation is solid. And always, always make the test results visible — dashboards, Slack notifications, PR check annotations. If people can't see the results, the pipeline doesn't exist.