MLOpsThe MLOps lifecycle · MLflow 3.17 · DVC 3.67 · FastAPI · Docker · GitHub Actions · Evidently · Python 3.11+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
50 small wins to finish your pathNext lesson →

Building the image in CI

Building the image in CI is a second job that waits for the tests, runs docker build on the runner and pushes the tagged image to a registry, so the artifact that reaches the server is built from the commit that passed rather than from someone's laptop.

Last updated: 19 Oct, 2026 · MLOps

The workflow from A GitHub Actions workflow proves a commit works. It does not produce anything. This lesson adds the job that turns the commit into the image from the Dockerising the service lesson and puts it in the registry, using the same Dockerfile that already sits in the repository.

The second job: buildx, login, build-and-push, and the digest output · from the MLOPS Tutorial- Automating Workflow Of CI/CD for Dockerized Flask App Using Github Action · 27:58 to 29:26

The clip pins docker/login-action@v2 and docker/build-push-action@v4, both since superseded, so this job pins the current majors; needs, context, push and tags and the steps.<id>.outputs.digest reference are unchanged.

Making the build wait for the tests

Jobs in a workflow run at the same time by default. needs puts one behind another: the build job starts only after the test job finished successfully, which is what makes the tests a gate rather than a report.

yaml
jobs:
  test:
    runs-on: ubuntu-latest
    # ... the steps from the test job

  build-and-push:
    needs: test
    runs-on: ubuntu-latest

Logging in to the registry

The runner is a new machine with no credentials, so the job logs in to the registry before it pushes. docker/login-action does it with a username and an access token. The token is kept as a repository secret and read as ${{ secrets.DOCKERHUB_TOKEN }}, which is the subject of Secrets and what never goes in git; the username is not a credential, so it is a repository variable.

yaml
      - name: Log in to Docker Hub
        uses: docker/login-action@v4
        with:
          username: ${{ vars.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

Building and pushing in one step

docker/build-push-action builds with Buildx and pushes in the same step. context is the directory the build sees, file is the Dockerfile to read, and push: true sends the result to the registry the login step authenticated against.

Training the model before the image is built

A fresh runner checks out the repository and starts with no build output of its own. The Dockerfile's last instruction copies artifacts/, which is deliberately not in git, so a build on that bare checkout fails with "/artifacts": not found. The job makes the artifacts first, from the same pipeline the training part built.

yaml
      - uses: actions/setup-python@v7
        with:
          python-version: "3.11"

      # the image copies artifacts/, which is not in git, so this job makes it first
      - name: Train the model the image will carry
        run: |
          pip install -r requirements.txt
          PYTHONPATH=src python -m sp.pipeline.train_pipeline
yaml
      - uses: docker/setup-buildx-action@v4

      - name: Build and push
        uses: docker/build-push-action@v7
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ vars.DOCKERHUB_USERNAME }}/student-performance:latest

Tagging with the commit, not only with latest

A single latest tag moves with every build, so a container running it cannot tell you which commit it came from. github.sha is the commit the workflow is running on, and an image tagged with it is traceable: a server showing a bad prediction names the exact source. Both tags are written in one build, so the two names point at the same image.

yaml
          tags: |
            ${{ vars.DOCKERHUB_USERNAME }}/student-performance:${{ github.sha }}
            ${{ vars.DOCKERHUB_USERNAME }}/student-performance:latest

Keeping the pip layer out of every build

A runner is thrown away after the job, so the local layer cache from the Smaller images with layer caching lesson is gone before the next run starts. Buildx can store that cache in the GitHub Actions cache instead, which is the point of copying requirements.txt before the source: the dependency layer is reused and only the layers after it are rebuilt.

yaml
          cache-from: type=gha
          cache-to: type=gha,mode=max

The build job in one piece

yaml
  build-and-push:
    needs: test                      # only runs when the test job passed
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-python@v7
        with:
          python-version: "3.11"

      # the image copies artifacts/, which is not in git, so this job makes it first
      - name: Train the model the image will carry
        run: |
          pip install -r requirements.txt
          PYTHONPATH=src python -m sp.pipeline.train_pipeline

      - uses: docker/setup-buildx-action@v4

      - name: Log in to Docker Hub
        uses: docker/login-action@v4
        with:
          username: ${{ vars.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      # two tags from one build: the commit for tracing, latest for the server
      - name: Build and push
        uses: docker/build-push-action@v7
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: |
            ${{ vars.DOCKERHUB_USERNAME }}/student-performance:${{ github.sha }}
            ${{ vars.DOCKERHUB_USERNAME }}/student-performance:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

What the job leaves behind

  • Two tags in the registry pointing at one image: the commit SHA, and latest.
  • A cache entry that the next run restores, so an unchanged requirements.txt does not install the libraries again.
  • Nothing on the runner. The machine is discarded, which is why the image has to be pushed somewhere before the job ends.

Building on a laptop against building in CI

By handIn the build job
What is builtYour working directory, uncommitted edits includedThe commit that passed the tests
Who can do itWhoever has Docker and the credentialsThe repository, on every push to main
The tagWhatever you typedThe commit SHA and latest, every time
The cacheYour local layer cacheThe Actions cache, restored into a fresh machine
Watch out. Pushing an image on every pull request puts untested code in the registry and tries to log in where it cannot: except for the automatic GITHUB_TOKEN, secrets are not passed to the runner when a workflow is triggered from a forked repository, so the login step of such a run fails. Push only on a push to main, and let a pull request build with push: false to prove the Dockerfile still works.
Try it yourself
  • Add a second tag with the branch name and watch three tags appear for one build.
  • Remove needs: test and read the run page: both jobs now start together.
  • Push twice without touching requirements.txt and compare how long the build step took the second time.

Little by little, you're building something great.