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:
An image name usually looks like this:
For repository-scoped images, I usually make the image name match the repository:
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: writepermission for automated publishing.
For the examples below, assume this small application layout:
Here is a deliberately tiny Dockerfile:
If you don't have an application binary yet and only want to test the registry flow, use this throwaway Dockerfile instead:
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 imagesread:packages, useful for pulling private imagesdelete: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:
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:
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:
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:
Login to the Registry¶
Docker authenticates to GHCR the same way it authenticates to other registries:
Using --password-stdin keeps the token out of your shell history and process list. Avoid this pattern:
It works, but it's easier to leak.
Build the Image¶
Build and tag the image using the full GHCR reference:
That produces a local image tagged like:
You can inspect it locally:
Run it before pushing:
Push the Image to the Registry¶
Push the image to GHCR:
After the push completes, the package should appear under:
For an organization package, use:
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:
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/checkoutto read the repository.docker/setup-buildx-actionto enable BuildKit and future multi-platform builds.docker/login-actionto authenticate to GHCR.docker/metadata-actionto generate tags and OCI labels.docker/build-push-actionto 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:
Without packages: write, the workflow can authenticate but fail when it tries to push.
Release-Driven Workflow¶
Create this file:
---
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:
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:
The latest tag is enabled only for non-prerelease releases:
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:
That can make later builds faster, especially for images with dependency installation steps.
Lowercase Image Names¶
The example uses:
That's convenient when your owner and repository names are already lowercase. If they're not, set a lowercase image name explicitly:
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:
---
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:
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_TOKENover 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.