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

  1. Confirm the image builds successfully locally or in CI before wiring up a registry push.
  2. Add a permissions block scoped to contents: read and packages: write — nothing broader.
  3. Add a docker/login-action step targeting ghcr.io with github.actor and secrets.GITHUB_TOKEN.
  4. Lowercase the image name if the owner or repo name contains uppercase characters.
  5. Use docker/metadata-action to derive tags from the branch, tag, and commit SHA instead of writing tag strings by hand.
  6. Push only on trusted events (push to a protected branch, or a release), never on pull_request from a fork.
  7. Capture steps.build.outputs.digest and pass it to any later deploy job.
  8. 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: read and packages: write for 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-action rather 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 (or pull_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-action so it produces a SHA-based tag and a branch-based tag instead of only latest.
  • Add a pull_request job that builds the image with push: false and 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.digest is 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.