Skip to content

GitHub Container Registry Workflow: Auth, Linkage and Provenance

Lesson 6 of 8Intermediate14 min readGit for DevOps & Infrastructure · ContainersVerified: GitHub Packages container registry documentation, September 2026

GHCR’s appeal is that the image ends up next to the code that produced it — same organisation, same permissions model, same audit trail.

That only holds if the linkage is actually established, and the default when you push from a laptop is that it is not. This lesson is about getting the connection right, and about the permission model underneath it, which is more subtle than “the repository is private so the image is too”.

ghcr.io/OWNER/IMAGE[:TAG]
ghcr.io/example-org/api:v2.4.1

OWNER is a user or organisation, and it must be lowercased. IMAGE is a name you choose — it does not have to match a repository, though matching makes the relationship obvious to anybody reading a manifest.

The lowercasing requirement causes a recurring failure: an organisation named Example-Org produces an invalid reference when interpolated directly, and the error mentions the reference format rather than the capitalisation. Where organisation names contain capitals, lowercase the value before using it.

In a workflow, ghcr.io/${{ github.repository }} gives ghcr.io/owner/repo directly, which is why it appears in almost every example.

Two paths, with different properties.

Use GITHUB_TOKEN. It publishes packages associated with the workflow’s own repository, and it is short-lived and scoped to the run.

permissions:
contents: read
packages: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

packages: write is required and is not a default. Workflow permissions must grant it explicitly, and a repository configured with restricted default workflow permissions will not have it even implicitly.

Grant it only in the job that pushes. A workflow-level packages: write gives every job in the file the ability to publish, including a job that only runs tests. Job-level permissions cost one extra block and keep the grant where it is needed.

This is the path that establishes repository linkage automatically, which is covered below and is the main reason to prefer it.

The registry supports authentication with a personal access token (classic), with scopes:

ScopeGrants
read:packagesPull images and read metadata
write:packagesPush, and manage metadata
delete:packagesDelete versions
Terminal window
echo "$GHCR_TOKEN" | docker login ghcr.io -u USERNAME --password-stdin

Do not hand out write:packages broadly. A token with it can publish under your organisation’s namespace, and an image published under a trusted namespace is one people will pull without much scrutiny — which is precisely what makes namespace write access valuable to somebody who should not have it. Treat it as a production credential rather than as developer convenience.

Prefer not pushing from laptops at all. A locally built image has no provenance, may come from a dirty working tree, does not link to a repository automatically, and was built with whatever toolchain that machine happens to have. CI-only pushing is the policy this cluster recommends, and the token scoping above is how you make it a control rather than an agreement.

The property that makes GHCR worth using, and the one that silently does not happen.

A linked package shows its source repository, can inherit permissions from it, and gives anybody looking at the image a route back to the code.

Pushed from Actions with GITHUB_TOKEN: linkage is automatic.

Pushed any other way: add the OCI source label.

LABEL org.opencontainers.image.source="https://github.com/example-org/api"

Or via the metadata action, which sets it from the Git context:

- uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}

Add the label regardless. It costs nothing, it survives the image being copied to another registry, and it is the same label that gives source traceability. Relying on the automatic linkage means the connection exists in GitHub’s metadata and not in the artifact — so an image mirrored elsewhere, or inspected by tooling that does not query GitHub, loses it entirely.

The label also fixes packages that were created before you got this right. Adding it and rebuilding establishes the linkage retroactively for the new version, which is the cheapest way to clean up a namespace full of unlinked packages.

The part that surprises people.

New packages default to private. Publishing from a public repository does not make the package public. This catches teams whose open-source project’s images cannot be pulled by anybody, and the symptom is a confused issue from a user.

Package visibility is separate from repository visibility. They are independent settings, and changing one does not change the other. A public repository can have a private package, and a private repository can have a public package — the second being a disclosure worth checking for if your organisation has been publishing for a while.

Permissions either inherit from a repository or are set granularly. Inheriting is usually what you want for a package belonging to one repository: repository collaborators get corresponding package access, and there is one place to manage it. Granular permissions are for packages several repositories consume.

Access for Actions and Codespaces is configured separately from human access. This is the setting people miss: a package a human can see may still be unpullable from a workflow, and the error gives no hint that the two are configured in different places. Check it first when a workflow in another repository cannot pull something you can see in the browser.

The decisions worth making deliberately:

PackageVisibilityPermissions
Open-source project’s imagePublicInherit
Internal service imagePrivateInherit from its repository
Shared base imagePrivateGranular — read for the org
Experimental or scratchPrivateInherit, with retention

A workflow in repository B pulling an image published by repository A needs access that GITHUB_TOKEN does not grant by default — its scope is its own repository.

The options:

Grant the consuming repository access to the package in the package’s settings. Cleanest for a small number of consumers.

Make the package internal or public, if that suits. An internal package readable across the organisation removes the per-repository grant entirely, and is appropriate for a shared base image.

Use a GitHub App or a fine-grained token for cross-organisation cases. A personal access token works and ties the access to a person, which breaks when they leave.

The failure looks like an authentication error on docker pull in a workflow that appears correctly configured. Check package access before debugging the login step.

The reason to prefer publishing from Actions beyond convenience.

- uses: docker/build-push-action@v7
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: true
sbom: true

Provenance records which workflow, repository and commit produced the image, signed by the build system.

SBOM enumerates the packages the image contains, which is what you query when an advisory lands and you need to know which of two hundred images are affected.

Both attach to the image in the registry and both are verifiable. Artifact attestations covers generating and verifying them properly, and signing container images covers the signature side.

The practical value: “this image came from our repository” becomes checkable rather than assumed. Without it, the only thing distinguishing a legitimate image in your namespace from one somebody pushed with a stolen token is that you believe the namespace is well-controlled.

At more than a handful of repositories, package publishing becomes a governance question rather than a per-repository one.

Decide who may create packages. An organisation where anybody can publish under the org namespace has a namespace whose trustworthiness rests on everybody’s credential hygiene. Restricting package creation, where the setting exists, is a control worth having.

Decide the default visibility. Private by default is the safe position and it produces confused users when an open-source project’s images cannot be pulled. Whichever you choose, make it a decision rather than a discovery.

Audit what exists. List the organisation’s packages and look for the ones with no linked repository — those were pushed by hand, and each is worth a question. This is the single most informative check available and almost nobody runs it.

Watch for packages nobody owns. A package whose repository was archived or deleted is still there, still pullable, and increasingly likely to be running somewhere with an unpatched base.

Apply the same review standard to workflow permissions. A pull request adding packages: write to a workflow is a pull request granting publish rights. .github/workflows/ under CODEOWNERS makes that visible.

Do not let the registry become the inventory. GHCR tells you what exists, not what is running. Those diverge quickly, and the second question is the one that matters during an advisory.

Teams arriving from Docker Hub or a cloud provider’s registry hit a consistent set of issues.

Existing tags do not move themselves. Copying images preserves digests if you use a registry-to-registry copy tool rather than pull-and-push through a local daemon — the second can re-compress layers and change the digest, which breaks every deployment pinned to it.

Digests referenced in manifests change if the copy changes them. Verify with a copy of one image before moving the fleet.

Rate limits differ. A pipeline tuned around one registry’s limits may behave differently.

Base images may need updating too. If your Dockerfiles pull base images from a registry you are leaving, that is a separate decision — and pulling bases from a registry you do not control is a supply-chain consideration regardless of where you push.

Run both for a period. Push to both registries during the transition, cut consumers over gradually, and decommission the old one only when nothing references it. Checking “nothing references it” is harder than it sounds; registry pull logs are the evidence.

Take the opportunity to add provenance. A migration is the natural moment to add attestations, because you are already touching every publishing workflow.

Untagged versions accumulate. Every rebuild that reassigns a tag leaves the previous version tagless.

Delete pull request images shortly after the pull request closes.

Keep release versions indefinitely. They are your rollback horizon.

Keep default-branch SHA-tagged images for a defined window covering rollback and investigation needs. Whatever that window is, it is your effective rollback horizon, and most teams have never stated it explicitly.

Be careful with untagged deletion. An untagged version may still be referenced by digest from a running deployment. Deleting it means a node that needs to pull cannot, and the service does not come back after a restart. Check what your clusters are actually running before enabling aggressive cleanup.

Deletion is permanent. There is no restore, no soft-delete window and no recovery from a mistaken policy. Before enabling any automatic cleanup, run it in report-only mode if the tooling allows, and read what it would have deleted.

The consuming side, which is where private packages meet Kubernetes.

A private package needs an image pull secret. The cluster is not authenticated to GHCR by default, and the symptom is a pod stuck in ImagePullBackOff with an authorisation error in its events.

Do not use a personal access token for this. It ties cluster access to an individual, it breaks when they leave, and it typically carries broader scope than pulling one package.

The better options: a GitHub App installation token refreshed by a controller, or a dedicated machine identity with read-only package access and nothing else. Either is more work to set up and considerably less work to live with.

The secret itself is a credential in the cluster, which brings it under the GitOps secrets problem — it must not be a plaintext manifest in a repository, and it needs rotating like anything else.

Consider making shared base images internal. Where organisation-wide read is acceptable, it removes the per-consumer grant entirely and is one fewer credential to manage.

Check the pull path before you need it. A cluster that has been running fine for months may be running images it pulled when the secret was valid. A node that reschedules a pod after the token expired discovers the problem at the worst moment, and the failure looks like an application problem rather than an authentication one.

Assuming a public repository means a public package. They are independent, and packages default to private.

No packages: write permission. The push fails with an authorisation error that does not name the missing permission.

packages: write at workflow level. Every job can publish.

No org.opencontainers.image.source label. Linkage depends on how it was pushed rather than on the artifact.

Pushing from a laptop. No provenance, no automatic linkage, possibly a dirty tree — and it can block CI from later owning the package name.

Broad write:packages tokens. Anybody holding one publishes under a namespace people trust.

Aggressive untagged cleanup. Deletes images that running deployments reference.

Forgetting cross-repository package access. GITHUB_TOKEN is scoped to its own repository.

No provenance. “It came from our repository” stays an assumption.

GHCR is the container registry within GitHub Packages, and the OCI format it speaks carries more than images.

Helm charts. Helm publishes charts to OCI registries, and GHCR is one. A chart pushed alongside the images it deploys keeps the artifact and its packaging in the same place with the same permissions — worth considering when you are already using GHCR for images.

OCI artifacts generally. Flux’s OCIRepository source reads Kubernetes manifests packaged as OCI artifacts, which is a way to satisfy the GitOps principles with a registry as the source rather than Git. Signed, versioned, immutable — the principles do not require Git specifically.

Attestations and SBOMs attach as OCI artifacts referencing the image.

What this means practically: the registry stops being “where images live” and becomes a general store for versioned, immutable, content-addressed artifacts. The tagging discipline from image tagging applies to all of them — a chart version is a tag with the same mutability properties, and a chart referenced by digest has the same guarantee.

One caution: more artifact types in one registry means more consumers needing access, which pushes toward broader permissions. Keep the grants scoped by artifact rather than granting read on everything because it is simpler.

The errors in this area are unhelpfully similar, and the diagnosis order saves time.

“denied” or “unauthorized” on push. In order: does the job have packages: write? Is it at job level or workflow level, and did you check the right one? Is the image name lowercased — GHCR requires it, and ${{ github.repository }} on an organisation with capitals produces an invalid reference. Was the package first created by a hand-push without linkage?

“denied” on pull from another repository. Package access, not login. Check the package’s settings for which repositories can read it, and remember Actions access is configured separately from human access.

ImagePullBackOff in a cluster. The image pull secret. Check it exists in the right namespace — pull secrets are namespaced, and a secret in default does nothing for a pod in production.

“manifest unknown”. The tag does not exist. Usually a retention policy, occasionally a typo, sometimes a push that failed after the tag was computed.

A push that succeeds but the package does not appear where expected. Check the owner in the image name. Pushing to ghcr.io/username/image from an organisation’s repository publishes to the user’s namespace, not the organisation’s, and it looks entirely successful.

The general approach: the registry’s errors describe what it refused, not why the configuration led there. Work from the permission model outward rather than from the error message inward.

GHCR puts the artifact next to the code, but only if the linkage is established and the permissions are set deliberately. The defaults are private and unlinked, which is safe and frequently not what people expect.

The three things worth getting right on day one: push from Actions, set the source label anyway, and check the package’s visibility rather than inferring it from the repository’s.

  • ghcr.io/OWNER/IMAGE; github.repository gives owner and repo directly
  • GITHUB_TOKEN with job-scoped packages: write is the right path from Actions
  • Elsewhere requires a classic PAT with read:/write:/delete:packages
  • Linkage is automatic from Actions; set org.opencontainers.image.source regardless
  • Packages default to private, independently of repository visibility
  • Permissions either inherit from a repository or are granular; Actions access is configured separately
  • Provenance and SBOM attestations make origin checkable rather than assumed
  • Untagged cleanup can delete images that running deployments reference by digest

Use a disposable repository under an account you control.

  1. Add a workflow that builds and pushes to GHCR using GITHUB_TOKEN with job-scoped packages: write.

  2. Push and check the package page. Predict: is it public or private? Is it linked to the repository?

  3. Remove packages: write and re-run. Read the error. Predict: does it name the missing permission?

  4. Make the repository public. Check the package’s visibility again. Predict: did it change?

  5. Add provenance: true and sbom: true. Push and inspect what attaches to the image.

  6. From a second repository, try to pull the package with its own GITHUB_TOKEN. Predict: does it work?

  7. Grant that repository package access and retry.

  8. Delete the package and the repository.

Lab: ship an infrastructure change through a pull requestPractise the review discipline that makes an infrastructure change reviewable before it is applied.

The GitOps and infrastructure repository templates are in the Professional Toolkit.