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.
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 checkConnect 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
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"
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 images — CI/CD with GitHub Actions