Publishing Images to GitHub Container Registry
GitHub Container Registry (GHCR) lets you store and version Docker images at ghcr.io, scoped to your GitHub user or organization and tied directly to repository permissions. Once your workflow can build an image, the next production concern is publishing it safely: authenticating without a long-lived password, tagging it so consumers can find the right version, and making sure only trusted, reviewed code ever gets pushed.
Overview / How it works
A publish step in Actions typically does three things: logs in to ghcr.io using the automatically generated GITHUB_TOKEN, builds the image with docker/build-push-action, and pushes it with one or more tags. GHCR associates the resulting package with the repository that pushed it, so access control (public, private, or inherited from the repo) can be managed from the repository’s Packages settings.
Two ideas matter more here than in a typical Docker Hub push. First, tags are mutable — pushing new content to an existing tag like latest silently changes what that tag points to. Second, every pushed image also gets an immutable digest (a sha256:... hash of the manifest). Anything you deploy to production should ultimately be pinned to a digest or an immutable version tag, not a floating tag, so a rollback or audit can point to exactly one set of bytes.
Syntax or workflow structure
A minimal GHCR publish job needs an explicit permissions block, because the default token permissions in many orgs do not include package writes:
permissions:
contents: read
packages: write
Authentication uses docker/login-action with the built-in actor and token — no personal access token is required for pushing to a package tied to the current repository:
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
Image names on GHCR must be lowercase. ${{ github.repository }} preserves whatever case the owner and repo name have, so if either contains uppercase letters the push fails. The safe pattern is to lowercase it explicitly before use, shown in Example 2 below.
Examples
Example 1: minimal push on merge to main. This builds and republishes the latest tag every time main changes.
name: Publish to GHCR
on:
push:
branches: [main]
permissions:
contents: read
packages: write
env:
IMAGE_NAME: ghcr.io/${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.IMAGE_NAME }}:latest
Expected behavior: after a merge to main, the Actions run shows a successful push, and the repository’s Packages tab lists ghcr.io/OWNER/REPO:latest pointing at the new digest. Anyone who already deployed :latest is now running different code than before — nothing tells them that happened.
Example 2: a real tagging strategy with docker/metadata-action. Instead of hand-writing tag strings, generate them consistently from the git ref, and fix the lowercase issue.
name: Publish to GHCR
on:
push:
branches: [main]
tags: ['v*.*.*']
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set lowercase image name
run: echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract tags and labels
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=sha,format=short
type=ref,event=branch
type=semver,pattern={{version}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Show pushed digest
run: echo "Image pushed at digest ${{ steps.build.outputs.digest }}"
Expected behavior: a push to main produces tags such as sha-6f3a9c1 and main, plus latest since it’s the default branch. Pushing a git tag like v1.4.0 additionally produces a 1.4.0 tag. The digest is printed so later jobs (or a human reading the log) can copy the exact reference.
Example 3: separating untrusted pull requests from trusted pushes, with pinned actions. A workflow triggered by pull_request can run against a fork’s code. It should build to verify the Dockerfile works, but never authenticate to the registry or push, since that would hand a registry-write credential to code you haven’t reviewed.
name: Build and Publish
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@<PINNED_COMMIT_SHA> # v4.1.7
- name: Build image (verification only, never pushed)
if: github.event_name == 'pull_request'
uses: docker/build-push-action@<PINNED_COMMIT_SHA> # v6.9.0
with:
context: .
push: false
- name: Log in to GHCR
if: github.event_name == 'push'
uses: docker/login-action@<PINNED_COMMIT_SHA> # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
if: github.event_name == 'push'
id: build
uses: docker/build-push-action@<PINNED_COMMIT_SHA> # v6.9.0
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}
- name: Record digest for the deploy job
if: github.event_name == 'push'
run: echo "IMAGE_DIGEST=${{ steps.build.outputs.digest }}" >> "$GITHUB_ENV"
Expected behavior: a pull request run builds the image and fails the job if the Dockerfile is broken, but produces no registry activity at all. Only a push to main (a merged, reviewed commit) authenticates and publishes. Replace each <PINNED_COMMIT_SHA> with the actual commit SHA of the release you’ve reviewed — GitHub’s UI shows it on each action’s release page.
A later deployment step should read the digest, not a tag:
docker pull ghcr.io/OWNER/REPO@sha256:${IMAGE_DIGEST}
Sample log output from the push step above:
#8 exporting to image
#8 pushing manifest for ghcr.io/octo-org/api-service:sha-6f3a9c1
#8 pushing manifest for ghcr.io/octo-org/api-service:sha-6f3a9c1@sha256:9c2b1f4e2a7d...
#8 DONE 1.3s
Image pushed at digest sha256:9c2b1f4e2a7d...
Step by step
- Confirm the image builds successfully locally or in CI before wiring up a registry push.
- Add a
permissionsblock scoped tocontents: readandpackages: write— nothing broader. - Add a
docker/login-actionstep targetingghcr.iowithgithub.actorandsecrets.GITHUB_TOKEN. - Lowercase the image name if the owner or repo name contains uppercase characters.
- Use
docker/metadata-actionto derive tags from the branch, tag, and commit SHA instead of writing tag strings by hand. - Push only on trusted events (
pushto a protected branch, or a release), never onpull_requestfrom a fork. - Capture
steps.build.outputs.digestand pass it to any later deploy job. - In the repository’s Packages settings, confirm the package’s visibility and, if needed, set it to inherit access from the source repository.
Common Mistakes
| Mistake | Why it fails | Correction |
|---|---|---|
No explicit packages: write permission |
Push fails with a permission-denied error even though the workflow otherwise looks correct | Add a permissions: block naming exactly the scopes the job needs |
Deploying by the :latest tag |
The tag is mutable; a later push silently changes what’s running, and there’s no single reference to roll back to | Deploy using the image digest (@sha256:...) captured from the build step’s output |
Building and pushing directly from a pull_request trigger with registry credentials available |
A pull request from a fork can contain an arbitrary Dockerfile or build script; running it with write access to your registry lets an attacker publish or exfiltrate through the build | Run pull_request builds with push: false and no login step; only authenticate and push on push to a protected branch |
Best Practices
- Grant only
contents: readandpackages: writefor a publish job; do not fall back on default permissions. - Prefer pinning third-party actions (like
docker/build-push-action) to a commit SHA rather than a floating major-version tag, and keep the version as a trailing comment for readability; this trades a small maintenance cost for protection against a compromised or force-pushed tag. - Generate tags with
docker/metadata-actionrather than string-concatenating them, so branch names, semver tags, and SHAs are handled consistently. - Treat tags as human-readable pointers and digests as the actual deployable reference.
- Never let a
pull_request(orpull_request_target) run from a fork reach a login step; if a workflow must run privileged steps against fork content, gate them behind manual approval via a protected environment. - Use a GitHub Environment with required reviewers or wait timers in front of any deploy job that consumes a freshly pushed image, so a bad build can be caught before it reaches production.
- Confirm package visibility explicitly after the first push — don’t assume it matches the repository’s visibility.
Practice Exercises
- Take Example 1 and rewrite it using
docker/metadata-actionso it produces a SHA-based tag and a branch-based tag instead of onlylatest. - Add a
pull_requestjob that builds the image withpush: falseand confirm in the Actions log that no login step runs for that event. - Add a step after the push that fails the job (using a simple shell check) if
steps.build.outputs.digestis empty, to guard against a misconfigured build step silently skipping the push. - Add a second job that depends on the publish job, pulls the image by digest, and runs a basic health check command against it before a hypothetical deploy step.
Summary
Publishing to GHCR from Actions is straightforward once three habits are in place: scope the token’s permissions explicitly, generate tags from a real strategy instead of hand-written strings, and never let untrusted pull request code reach a login step. The digest that comes back from the push is the actual contract for what gets deployed — tags are for humans browsing the registry, digests are for machines running the code.
