Thuta Learning
ProjectsDevOps & Toolsbeginner

Project: Build and Push a Docker Image

What you'll walk away with

  • Explain the core ideas behind Project: Build and Push a Docker Image
  • Read the diagram and trace how the pipeline flows and what gates each stage
  • Read the workflow file and predict which jobs run, in what order, and what gates them

Build the mental model

Pushing an image is where a workflow stops being a checker and becomes a producer, and producing artifacts is where permissions matter. GitHub Container Registry accepts the built-in GITHUB_TOKEN, so there is no personal access token to create, rotate or leak -- but only if the job asks for the right scopes. Declaring permissions at job level with contents: read and packages: write does two things: it grants the write scope the push needs and, because naming any permission resets all the others to none, strips every scope this job does not. Least privilege is not a nice-to-have here: a compromised build step holding a token that can write issues, releases and code is a far worse day than one that can only push a package.

Then, conditional execution. The workflow runs on pull requests too, because you want to know the Dockerfile still builds before anyone merges. It must not push. A fork's pull request receives a read-only GITHUB_TOKEN, so a push would fail anyway, but relying on that failure is backwards. If the token were ever broader you would be publishing a stranger's unreviewed code into your registry. So the login step is guarded by if: github.event_name != 'pull_request', and build-push-action is given push: ${{ github.event_name != 'pull_request' }}. Same job, same steps, different ending.

metadata-action turns git refs into tags so you never hand-write them. The important one is type=sha. A branch tag like main moves: the image it pointed at yesterday is gone. A SHA tag is immutable and traceable to exactly one commit, which turns a rollback into a redeploy of a known image rather than a rebuild and a hope.

Finally, cache-from and cache-to with type=gha put Docker's layer cache into the Actions cache, so unchanged layers survive between runs on brand-new runners.

text
IMAGE PIPELINE - PUSH VERSUS PULL REQUEST
-----------------------------------------
  push to main  or  tag v*            pull_request (incl. forks)
            |                                     |
            v                                     v
     +-------------+                       +-------------+
     |    test     |                       |    test     |
     +-------------+                       +-------------+
            |  needs: test                        |  needs: test
            v                                     v
  +-----------------------------+     +-----------------------------+
  | image                       |     | image                       |
  | permissions:                |     | permissions:                |
  |   contents: read            |     |   contents: read            |
  |   packages: write           |     |   packages: write           |
  |                             |     |                             |
  | login-action    -> ghcr.io  |     | login-action    -> SKIPPED  |
  | metadata-action -> tags     |     | metadata-action -> tags     |
  | build-push      -> push:yes |     | build-push      -> push:no  |
  | cache-from/to   -> type=gha |     | cache-from/to   -> type=gha |
  +-----------------------------+     +-----------------------------+
            |                                     |
            v                                     v
   ghcr.io/OWNER/REPO:main              nothing published; the build
   ghcr.io/OWNER/REPO:sha-<full sha>    result is only a green check

Connect it to a real scenario

The workflow opens with a test job. Because image carries needs: test, a failing test means the image is never even built, which is the earliest possible place to stop a broken image from reaching the registry.

Inside the image job the step order matters. setup-buildx-action installs the BuildKit builder, and cache-from and cache-to simply do not work without it. Then login, then metadata, then build-push. metadata-action publishes the tag and label lists it computed as steps.meta.outputs.tags and steps.meta.outputs.labels, which build-push-action consumes directly, so every rule about how branches, semver tags, pull requests and SHAs become image tags lives in exactly one place.

From a production standpoint the real value here is that two kinds of tag are produced at once. The main tag serves whoever just wants the latest build; the sha-<full sha> tag exists for the deployment record. Deployment systems should never reference main. They should reference the SHA tag, because when something breaks at six in the evening, rolling back is then a matter of pointing at yesterday's SHA tag. No archaeology through git history, no rebuild of an old commit on a toolchain that has since moved.

cache-to: type=gha,mode=max is worth the extra bytes for multi-stage Dockerfiles: it stores intermediate layers as well as final ones. The default, mode=min, keeps only the layers of the final stage, which means the expensive dependency-install stage is rebuilt on every single run.

Try the working example

yaml
name: Build and Push Image

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm
      - run: npm ci
      - run: npm test

  image:
    name: Build image
    needs: test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - name: Set up Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GitHub Container Registry
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Derive tags and labels
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=sha,format=long
          labels: |
            org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
            org.opencontainers.image.revision=${{ github.sha }}

      - name: Build and conditionally push
        uses: docker/build-push-action@v6
        with:
          context: .
          file: ./Dockerfile
          platforms: linux/amd64
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Record pushed tags in the run summary
        if: github.event_name != 'pull_request'
        run: |
          echo "### Pushed image tags" >> "$GITHUB_STEP_SUMMARY"
          echo "${{ steps.meta.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
You should see
On a push to main, the test job runs first and the image job starts only if it passes. The image job sets up Buildx, logs in to ghcr.io with GITHUB_TOKEN, has metadata-action derive a tag list from the branch name and the commit SHA, and builds the image with Buildx reusing whatever layers are still valid in the Actions cache. It then pushes both kinds of tag -- the branch tag and the SHA tag -- to the registry and writes the changed layers back to the cache. The list of pushed tags appears in the run summary. When you push a version tag such as v1.2.3, the semver pattern adds a matching version tag as well.

On a pull request the sequence is the same but the ending is not. The login step is skipped by its if condition, while metadata-action still computes a pr tag. build-push-action receives push: false, so the image is built and then discarded rather than published anywhere. A broken Dockerfile therefore turns the pull request red immediately while leaving the registry untouched, and the fact that fork pull requests cannot read secrets never becomes a conflict, because this shape never asks them to.

5-minute try-it

Add this workflow to a repository that has a Dockerfile and try three things. First, delete packages: write from the permissions block and push to main. Watch how the push step fails and read what the error says about scopes, then put it back. Second, open a pull request and confirm that the image job goes green while nothing new appears on the repository's Packages page. Third, remove the cache-to line, run the workflow twice in a row, and compare the build durations against the cached version to see what layer caching is actually buying you. As a stretch, add linux/arm64 to platforms and observe what changes about the build and about the manifest that gets pushed.

One important caution

Leaving push: true on pull requests. Fork pull requests get a read-only GITHUB_TOKEN, so those jobs go red on every contribution, and the usual fixes make it worse: switching to pull_request_target or adding a broad personal access token hands unreviewed code the ability to publish into your registry.

Referencing images as :latest or :main in your deployments. Those tags move, so the image you deploy today is not the image you tested yesterday, and when you need to roll back there is no stable name left to point at.

GitHub Docs - Publishing Docker imagesCI/CD with GitHub Actions

Easy traps

  • Leaving push: true on pull requests. Fork pull requests get a read-only GITHUB_TOKEN, so those jobs go red on every contribution, and the usual fixes make it worse: switching to pull_request_target or adding a broad personal access token hands unreviewed code the ability to publish into your registry.
  • Referencing images as :latest or :main in your deployments. Those tags move, so the image you deploy today is not the image you tested yesterday, and when you need to roll back there is no stable name left to point at.
  • Validate a workflow on a branch or test repository before pointing it at a production deployment.

Exercise

Add this workflow to a repository that has a Dockerfile and try three things. First, delete packages: write from the permissions block and push to main. Watch how the push step fails and read what the error says about scopes, then put it back. Second, open a pull request and confirm that the image job goes green while nothing new appears on the repository's Packages page. Third, remove the cache-to line, run the workflow twice in a row, and compare the build durations against the cached version to see what layer caching is actually buying you. As a stretch, add linux/arm64 to platforms and observe what changes about the build and about the manifest that gets pushed.

You'll know it worked when: On a push to main, the test job runs first and the image job starts only if it passes. The image job sets up Buildx, logs in to ghcr.io with GITHUB_TOKEN, has metadata-action derive a tag list from the branch name and the commit SHA, and builds the image with Buildx reusing whatever layers are still valid in the Actions cache. It then pushes both kinds of tag -- the branch tag and the SHA tag -- to the registry and writes the changed layers back to the cache. The list of pushed tags appears in the run summary. When you push a version tag such as v1.2.3, the semver pattern adds a matching version tag as well. On a pull request the sequence is the same but the ending is not. The login step is skipped by its if condition, while metadata-action still computes a pr tag. build-push-action receives push: false, so the image is built and then discarded rather than published anywhere. A broken Dockerfile therefore turns the pull request red immediately while leaving the registry untouched, and the fact that fork pull requests cannot read secrets never becomes a conflict, because this shape never asks them to.

Project: Build and Push a Docker Image | Thuta Learning