Skip to content

Publishing Docker Images to GitHub Container Registry

GitHub Container Registry ("GHCR") is GitHub's OCI-compatible container registry at ghcr.io. If your source code, releases, issues, permissions, and automation already live in GitHub, publishing container images to GHCR keeps the distribution path close to the repository that produced the image.

In this post, we'll publish a Docker image two ways: first manually from a local terminal, then automatically from GitHub Actions whenever a GitHub Release is published. Along the way, we'll cover image naming, authentication, permissions, release-driven tags, OCI labels, and the small details that make GHCR feel predictable instead of mysterious.

GHCR differs from a generic public registry when your project is already GitHub-centered:

  • Images can be associated with a repository.
  • Repository permissions can be used for package access.
  • GitHub Actions can publish with the built-in GITHUB_TOKEN.
  • Images can be public or private.
  • Tags, labels, and package metadata sit near the release and source history.
  • The registry supports standard Docker and OCI tooling.

Image Naming

The registry host is:

ghcr.io

An image name usually looks like this:

ghcr.io/OWNER/IMAGE_NAME:TAG

For repository-scoped images, I usually make the image name match the repository:

ghcr.io/tenthirtyam/example:v1.0.0

Normalize Image Names to Lowercase

Container image names must be lowercase. If your GitHub user, organization, repository, or image name contains uppercase characters, normalize it before tagging.

Prerequisites

You need a few things before publishing:

  • A GitHub account.
  • A repository containing a Dockerfile.
  • Docker installed locally for the manual approach.
  • GitHub CLI installed locally if you want the CLI-assisted token workflow.
  • Permission to create packages under the target user or organization.
  • A Personal Access Token for manual local pushes.
  • A GitHub Actions workflow with packages: write permission for automated publishing.

For the examples below, assume this small application layout:

.
├── Dockerfile
└── app

Here is a deliberately tiny Dockerfile:

Dockerfile
FROM alpine:3.23

COPY app /usr/local/bin/app

ENTRYPOINT ["/usr/local/bin/app"]

If you don't have an application binary yet and only want to test the registry flow, use this throwaway Dockerfile instead:

Dockerfile
FROM alpine:3.23

RUN printf '#!/bin/sh\necho "hello from ghcr"\n' > /usr/local/bin/app \
  && chmod +x /usr/local/bin/app

ENTRYPOINT ["/usr/local/bin/app"]

That gives you a container that can build and run without bringing a full app into the example.

Part 1: Manual Approach

Manual publishing is useful when you're learning GHCR, testing an image name, or pushing a one-off development image. For repeatable releases, automation is better, but the manual path teaches the mechanics.

Create a Token

For local pushes to GHCR, use a GitHub Personal Access Token rather than your account password. For a classic PAT, grant:

  • write:packages, required to push images
  • read:packages, useful for pulling private images
  • delete:packages, optional, only if you intend to delete package versions through the API

If the package is tied to a private repository, GitHub may require broader repository access depending on how the token is created and what you're trying to read. Keep token scopes as narrow as your use case allows.

GitHub doesn't provide a normal shell command that safely creates a classic PAT non-interactively. The most practical CLI-assisted path is to open the token creation page, create the token in the browser, then paste it into your shell.

gh browse "https://github.com/settings/tokens/new?scopes=write:packages,read:packages&description=ghcr-local-push"

After you create the token, store it in an environment variable for the current shell session:

read -rs GHCR_TOKEN
export GHCR_TOKEN

Paste the token when prompted. read -s keeps it out of your terminal history and avoids echoing it to the screen.

If you already use GitHub CLI and prefer to use its OAuth token for local testing, refresh the required package scopes and export the token:

gh auth refresh -h github.com -s write:packages,read:packages
export GHCR_TOKEN="$(gh auth token)"

That's convenient for development, but for a documented manual process I still prefer naming the variable GHCR_TOKEN and treating it like a short-lived credential.

Set Image Variables

Define the owner, image name, and tag once so you don't mistype them across commands:

OWNER="tenthirtyam"
IMAGE_NAME="example"
TAG="v1.0.0"
IMAGE="ghcr.io/${OWNER}/${IMAGE_NAME}:${TAG}"

For your own project, replace OWNER with your GitHub username or organization. Keep it lowercase.

If you want to derive the owner from the authenticated GitHub CLI account:

OWNER="$(gh api user --jq '.login' | tr '[:upper:]' '[:lower:]')"

Login to the Registry

Docker authenticates to GHCR the same way it authenticates to other registries:

echo "${GHCR_TOKEN}" | docker login ghcr.io --username "${OWNER}" --password-stdin

Using --password-stdin keeps the token out of your shell history and process list. Avoid this pattern:

docker login ghcr.io --username "${OWNER}" --password "${GHCR_TOKEN}"

It works, but it's easier to leak.

Build the Image

Build and tag the image using the full GHCR reference:

docker build --tag "${IMAGE}" .

That produces a local image tagged like:

ghcr.io/tenthirtyam/example:v1.0.0

You can inspect it locally:

docker image inspect "${IMAGE}"

Run it before pushing:

docker run --rm "${IMAGE}"

Push the Image to the Registry

Push the image to GHCR:

docker push "${IMAGE}"

After the push completes, the package should appear under:

https://github.com/users/OWNER/packages/container/package/IMAGE_NAME

For an organization package, use:

https://github.com/orgs/OWNER/packages/container/package/IMAGE_NAME

If this is the first time you have pushed the package, open the package settings in GitHub and confirm the package is connected to the repository you expect. That connection matters for visibility, repository access inheritance, and GitHub Actions publishing permissions.

Pull the Image to Verify

A quick pull test confirms the image is actually available from the registry:

docker pull "${IMAGE}"
docker run --rm "${IMAGE}"

If the package is private, the pulling account also needs permission to read it.

Publish a latest Tag

Release tags are better than relying only on latest, but many users still expect latest to exist for quick tests.

You can add a second tag locally:

LATEST_IMAGE="ghcr.io/${OWNER}/${IMAGE_NAME}:latest"
docker tag "${IMAGE}" "${LATEST_IMAGE}"
docker push "${LATEST_IMAGE}"

For production workflows, use immutable version tags for deployments and reserve latest for humans, examples, or non-critical pull commands.

Part 2: Automated Approach with GitHub Actions

Manual publishing is educational. Automated publishing is what you want for releases.

The workflow below publishes an image whenever a GitHub Release is published. It uses:

  • actions/checkout to read the repository.
  • docker/setup-buildx-action to enable BuildKit and future multi-platform builds.
  • docker/login-action to authenticate to GHCR.
  • docker/metadata-action to generate tags and OCI labels.
  • docker/build-push-action to build and push the image.
  • secrets.GITHUB_TOKEN, not a manually created PAT.

The key difference from the manual approach is permissions. The workflow grants the built-in token permission to write packages:

permissions:
  contents: read
  packages: write

Without packages: write, the workflow can authenticate but fail when it tries to push.

Release-Driven Workflow

Create this file:

.github/workflows/publish-container.yml
---
name: Publish Container Image

on:
  # Publish an image only after a GitHub Release becomes visible to users.
  release:
    types: [published]

permissions:
  # Required to clone the repository.
  contents: read
  # Required for GITHUB_TOKEN to push images to GHCR.
  packages: write

env:
  REGISTRY: ghcr.io
  # This produces ghcr.io/OWNER/REPOSITORY. Use a lowercase literal if your owner or repo has uppercase characters.
  IMAGE_NAME: ${{ github.repository }}

jobs:
  publish:
    name: Build and publish image
    runs-on: ubuntu-latest

    steps:
      # The Docker build context comes from the checked-out repository.
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      # Buildx enables BuildKit features such as cache exports and multi-platform builds.
      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1

      # GITHUB_TOKEN is enough here because this workflow grants packages: write above.
      - name: Login to Registry
        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Generate release-aware image tags and standard OCI labels.
      - name: Extract Docker Metadata
        id: metadata
        uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          # Preserve v1.2.3, add 1.2.3 and 1.2, then publish latest for stable releases only.
          tags: |
            type=raw,value=${{ github.event.release.tag_name }}
            type=semver,pattern={{version}},value=${{ github.event.release.tag_name }}
            type=semver,pattern={{major}}.{{minor}},value=${{ github.event.release.tag_name }}
            type=raw,value=latest,enable=${{ !github.event.release.prerelease }}
          labels: |
            org.opencontainers.image.title=${{ github.event.repository.name }}
            org.opencontainers.image.description=${{ github.event.repository.description }}
            org.opencontainers.image.source=${{ github.event.repository.html_url }}
            org.opencontainers.image.version=${{ github.event.release.tag_name }}

      # Build the Dockerfile, apply the generated tags and labels, then push to GHCR.
      - name: Build and Push Image
        uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ steps.metadata.outputs.tags }}
          labels: ${{ steps.metadata.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

This workflow is intentionally small. It does one job: when a release is published, build the image from the repository's Dockerfile, tag it from the release version, and push it to GHCR.

Why You Should Pin GitHub Actions to Commit Hashes

Every uses: reference in the workflows below is pinned to a full commit SHA with a version comment. That matches the guidance in Why You Should Pin GitHub Actions to Commit Hashes. If you already publish release artifacts with GoReleaser, see GoReleaser Deep Dive for a related container publishing workflow.

What Each Step Does

actions/checkout gives the runner access to your repository content. Docker cannot build what the runner hasn't checked out.

docker/setup-buildx-action configures Docker Buildx. Even if you only build linux/amd64 today, Buildx gives you BuildKit caching and a straightforward path to multi-platform images later.

docker/login-action logs in to ghcr.io using the GitHub Actions identity:

username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

No manual PAT is needed because the workflow token is minted automatically for the run. The permissions block decides what that token can do.

docker/metadata-action turns release context into image tags and labels. If the release tag is v1.2.3, the workflow preserves that exact tag and also generates semver-friendly tags:

ghcr.io/owner/repository:v1.2.3
ghcr.io/owner/repository:1.2.3
ghcr.io/owner/repository:1.2

The latest tag is enabled only for non-prerelease releases:

type=raw,value=latest,enable=${{ !github.event.release.prerelease }}

That avoids pointing latest at something like v2.0.0-rc.1.

docker/build-push-action builds and pushes. The cache settings use GitHub Actions cache storage for Docker layers:

cache-from: type=gha
cache-to: type=gha,mode=max

That can make later builds faster, especially for images with dependency installation steps.

Lowercase Image Names

The example uses:

IMAGE_NAME: ${{ github.repository }}

That's convenient when your owner and repository names are already lowercase. If they're not, set a lowercase image name explicitly:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: owner/repository

Docker image references are case-sensitive and must be lowercase.

Multi-Platform Variant

If you want to publish both linux/amd64 and linux/arm64, add QEMU and set platforms:

.github/workflows/publish-container.yml
---
name: Publish Multi-Platform Container Image

on:
  release:
    types: [published]

permissions:
  contents: read
  packages: write

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

jobs:
  publish:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Setup QEMU
        uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0

      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1

      - name: Login to Registry
        uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract Docker Metadata
        id: metadata
        uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=raw,value=${{ github.event.release.tag_name }}
            type=semver,pattern={{version}},value=${{ github.event.release.tag_name }}
            type=raw,value=latest,enable=${{ !github.event.release.prerelease }}

      - name: Build and Push Image
        uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
        with:
          context: .
          file: ./Dockerfile
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.metadata.outputs.tags }}
          labels: ${{ steps.metadata.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

The result is one tag backed by a manifest list. Docker clients automatically pull the right image for their architecture.

Verifying the Published Image

After publishing a release, check the package page in GitHub, or pull the image locally:

OWNER="tenthirtyam"
IMAGE_NAME="example"
TAG="1.0.0"

docker pull "ghcr.io/${OWNER}/${IMAGE_NAME}:${TAG}"
docker run --rm "ghcr.io/${OWNER}/${IMAGE_NAME}:${TAG}"

If you used ${{ github.repository }} as the image name, the image path includes both owner and repository:

docker pull ghcr.io/tenthirtyam/example:1.0.0

For private packages, make sure your local Docker client is logged in with a token that has read:packages.

Some Security Notes

Treat registry credentials like production credentials.

For local publishing:

  • Use a token with the minimum required scopes.
  • Prefer --password-stdin.
  • Don't paste tokens directly into commands.
  • Rotate tokens that were used on shared machines.

For GitHub Actions:

  • Prefer GITHUB_TOKEN over long-lived PAT secrets.
  • Set explicit workflow permissions.
  • Pin third-party actions to full commit SHAs, as shown in the workflows above.
  • Publish immutable version tags.
  • Use OCI labels so consumers can trace images back to source and release metadata.

Publishing to GHCR manually is straightforward: create a token, Login to ghcr.io, build a fully qualified image tag, and push it. That manual path is useful for learning and troubleshooting.

For real releases, GitHub Actions is the better model. A release-triggered workflow gives you the same build every time, derives tags from the release version, attaches useful metadata, avoids manual credentials, and reduces the chance that someone publishes the wrong image from the wrong machine.

Once that workflow exists, publishing a container image becomes part of the release process instead of a separate checklist. That's exactly where it belongs.