devopsbymuh_

MLOps for DevOps Engineers · Part 4 of 10

10 min readby Muhammad Rashid

CI/CD for MLOps with GitHub Actions

Automate the gate: test the model API, build the image, scan it for CVEs with Trivy, and push to Amazon ECR — with OIDC instead of long-lived keys and the git SHA as an immutable image tag. Part 4 of the MLOps series.

GitHub ActionsDevOpsAWSSeries
CI/CD for MLOps with GitHub Actions

In part three we built an image that runs the same everywhere. Then we built it by hand, on a laptop, and ran the tests when we remembered to. That's the new "runs on my machine": it builds on my machine. The whole reason we containerized was to stop trusting any single machine — so hand-building the image quietly re-introduces the problem we just paid to solve.

So we hand the entire ritual to GitHub Actions. Every push runs the tests. Every merge to main builds the image, scans it for known vulnerabilities, and pushes it to Amazon ECR — tagged so you can trace any running container back to the exact commit that produced it. "It's tested, it builds, and it's not full of CVEs" stops being a thing you promise and becomes a thing the pipeline proves.

The pipeline mechanics are ordinary DevOps. The one MLOps-flavored idea to hold onto: your tests are green does not yet mean your model is good. We wire the plumbing now; the model-quality gate arrives in part five, and it plugs into exactly this pipeline.

The shape: two gates

The pipeline is two jobs in sequence. Gate one runs on every push and pull request: install, run the tests. Gate two runs only on main, and only if gate one passed: build the image, scan it, push it. Nothing reaches ECR that didn't first pass the tests — the ordering is the safety.

A two-gate pipeline: gate one runs tests on every push; gate two builds, scans, and pushes only on main after tests pass
Two gates: tests on every push, then build-scan-push on main. Nothing ships untested.

Gate one: test the model API

We already wrote the tests in part two — the ones that hit /health, check a real prediction comes back, and confirm a latitude of 999 is rejected with a 422 before the model ever runs. CI's job is just to run them on a clean machine, every time, so "works on my laptop" is never the last word.

yaml
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.10"
          cache: pip
      - name: Install dependencies
        run: pip install -r requirements.txt
      - name: Run tests
        run: pytest -q

One quietly important detail: our model.joblib is committed to the repo, so the checkout brings the artifact with it and the tests can load a real model on a runner that has never seen our training data. That works because the model is a 47MB file — just under GitHub's 50MB soft limit. It's not "small" so much as small enough, and Git will carry those 47MB in every clone from here on, which is one more reason part five moves the artifact into MLflow. Then this step changes from "checkout the artifact" to "pull the artifact," and the test job barely notices — it doesn't care where the model comes from, only that it loads.

Gate two: build, scan, push

This job runs only on main and only after tests pass. It reuses the exact Dockerfile from part three, so CI builds the identical image you build locally — no separate "CI build" that drifts from reality.

yaml
  build-scan-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t "$ECR_REPOSITORY:${{ github.sha }}" .

      - name: Scan image with Trivy
        uses: aquasecurity/trivy-action@0.24.0
        with:
          image-ref: ${{ env.ECR_REPOSITORY }}:${{ github.sha }}
          format: table
          exit-code: "1"          # fail the build on a finding
          severity: CRITICAL,HIGH
          ignore-unfixed: true

Why scanning matters more for ML images

A minimal Go service ships a handful of dependencies. Our image ships scikit-learn, and underneath it numpy, scipy, and a pile of transitive C libraries — a genuinely large surface where a CVE can hide. Trivy reads the image, cross-references its packages against vulnerability databases, and here we tell it to fail the build (exit-code 1) on any fixable HIGH or CRITICAL. ignore-unfixed keeps the gate honest: it won't block you on a vulnerability that has no patch yet, because failing a build nobody can fix just trains people to ignore the scanner.

This is the security-scanning step from any mature pipeline, but for ML it's not optional theater. The dependency-heaviest part of your stack is the ML library you didn't write and can't easily audit. Letting a scanner watch it on every build is the cheapest insurance in the series.

Push to ECR — with no long-lived keys

The last step ships the image to Amazon ECR. The interesting part isn't the push; it's how we authenticate. We do not put an AWS access key in GitHub secrets — a long-lived key sitting in CI is exactly the credential that leaks and ends up mining crypto on someone's account. Instead we use OIDC: GitHub hands AWS a short-lived, cryptographically-signed token that proves "this is a workflow from this repo," and AWS trades it for temporary credentials that expire in minutes.

GitHub Actions exchanges a short-lived signed OIDC token with AWS STS for temporary credentials, instead of storing a long-lived access key
OIDC: a short-lived signed token traded for temporary credentials — no stored AWS key.
yaml
      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<ACCOUNT_ID>:role/github-actions-ecr-push
          aws-region: ${{ env.AWS_REGION }}

      - name: Log in to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Tag and push
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        # Push the immutable SHA tag only (the ECR repo is IMMUTABLE — part 7).
        run: |
          docker tag "$ECR_REPOSITORY:$IMAGE_TAG" "$REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
          docker push "$REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"

That role and its trust policy — "only this repo, only these branches, may assume me" — is IAM we'll define as code in part seven, not click together in the console. For now the mental model is enough: the pipeline proves who it is, gets a temporary key, and pushes. The permissions: id-token: write at the top of the workflow is what lets GitHub mint that OIDC token in the first place; forget it and the whole auth dance fails with a confusing error.

Tag by commit SHA — and only the SHA

We tag every image with the git SHA, and that's the only tag we push. It's tempting to also move a latest tag, but latest is a moving target that means something different every merge — and in part seven we make the ECR repo immutable, which rejects a re-pushed tag anyway. house-price-api:9f2c1a is forever exactly the code and model that produced it. When a bad prediction shows up in production in part eight, "which image is running" is a SHA you can check out, rebuild, and reproduce. This is the same immutability instinct we applied to the model artifact in part one, now applied to the image.

And it's a hint of the gap part five closes. An image SHA pins your code and your artifact-as-a-file. It does not tell you which model version, trained on which data, is inside — or whether that model is actually better than the one it's replacing. A green pipeline means the tests passed. It still can't tell you the model is any good. That's the exact problem the Model Registry exists for.

What's next

We've got a pipeline that tests on every push and ships a scanned, traceable image to ECR on every merge, authenticated without a single stored secret. The plumbing is done. What's missing is the one gate that makes this MLOps instead of DevOps-with-a-model-inside: proving the new model is better before it's allowed through. In part five we bring in MLflow — experiment tracking and a Model Registry — so "which model, trained how, and is it an improvement" becomes a question with an answer. The full workflow file is in the series repo. See you in part five.

$ subscribe --free

Want to build this hands-on? Every guide becomes a project on the channel.

Subscribe on YouTube