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 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.
jobs:
test:
runs-on: ubuntu-latest
# ... the steps from the test job
build-and-push:
needs: test
runs-on: ubuntu-latestLogging 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.
- 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.
- 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: Build and push
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ vars.DOCKERHUB_USERNAME }}/student-performance:latestTagging 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.
tags: |
${{ vars.DOCKERHUB_USERNAME }}/student-performance:${{ github.sha }}
${{ vars.DOCKERHUB_USERNAME }}/student-performance:latestKeeping 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.
cache-from: type=gha
cache-to: type=gha,mode=maxThe build job in one piece
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=maxWhat 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.txtdoes 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 hand | In the build job | |
|---|---|---|
| What is built | Your working directory, uncommitted edits included | The commit that passed the tests |
| Who can do it | Whoever has Docker and the credentials | The repository, on every push to main |
| The tag | Whatever you typed | The commit SHA and latest, every time |
| The cache | Your local layer cache | The Actions cache, restored into a fresh machine |
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.Related
- Previous: A GitHub Actions workflow
- Next: Deploying with a self-hosted runner
- The image this job builds: Dockerising the service
- Reference: docker/build-push-action and Docker build cache in GitHub Actions
- Add a second tag with the branch name and watch three tags appear for one build.
- Remove
needs: testand read the run page: both jobs now start together. - Push twice without touching
requirements.txtand compare how long the build step took the second time.
Little by little, you're building something great.