Pull from GitHub Container Registry (GHCR)

Authenticate Rune to ghcr.io so it can pull private images, using a GitHub personal access token or an existing docker login.

GHCR (ghcr.io) is a container registry hosted by GitHub. Public images work with no setup — Rune pulls them like any other registry. Private images need credentials: a GitHub token with read:packages, registered with runed so the Docker runner can use it on every pull.

This guide covers the two supported flows:

  1. Direct (recommended) — paste a GitHub PAT into a registry entry.
  2. From an existing docker login — reuse ~/.docker/config.json.

Both flows result in ghcr.io images pulling without ImageUnreachable on rune cast.

1. Mint a GitHub token

Generate a Personal Access Token at github.com/settings/tokens with the read:packages scope:

  • Classic PAT: check read:packages. (repo is not required for pulling.)
  • Fine-grained PAT: under Permissions → Account permissions set Packages to Read-only.

For organization-owned packages, also make sure the token's user has read access to the package on the Packages → Package settings → Manage Actions access page. A token can have the right scope but still get denied: permission_denied if the package itself doesn't grant the user access.

Test the token from a shell:

echo "$GHCR_PAT" | docker login ghcr.io -u <github-username> --password-stdin
docker pull ghcr.io/<org>/<image>:<tag>

If docker pull works, Rune will work — the same daemon performs both pulls.

2a. Direct: register the token with runed

There are two equivalent ways to add a registry entry: at runtime via rune admin registry add, or statically in the runefile.

Runtime (no restart)

Run from the same host as runed (admin commands are localhost-only by default):

rune admin registry add \
  --name ghcr \
  --registry ghcr.io \
  --type basic \
  --username "$GITHUB_USER" \
  --password "$GHCR_PAT"

Verify:

rune admin registry list
rune admin registry test ghcr

test performs a real auth handshake and returns ok or the registry error message verbatim.

Static (in the runefile)

docker:
  registries:
    - name: ghcr
      registry: ghcr.io
      auth:
        type: basic
        username: ${GHCR_USER}     # env-expanded at runed start
        password: ${GHCR_PAT}

runed reads its config at startup, so changes here require a restart. Prefer the rune admin registry route once the cluster is running.

:::caution[No password-file field] The runefile schema does not support password-file:password must be inline (use ${ENV} expansion to keep the literal token out of the file), or sourced from a Rune Secret via auth.fromSecret: <name>. :::

2b. Reuse an existing docker login

If the host already has working docker login ghcr.io, you can hand the contents of ~/.docker/config.json to Rune verbatim instead of re-typing the credentials:

rune admin registry add \
  --name ghcr-from-docker \
  --registry ghcr.io \
  --type dockerconfigjson \
  --data dockerconfigjson="$(cat ~/.docker/config.json)"

This is useful when:

  • You manage GHCR auth via a CI step that runs docker login for you (e.g. docker/login-action on a self-hosted runner).
  • You want to ship one credential blob that covers multiple registries in a single file.

3. Cast a private image

Once the registry is registered, no service-spec change is needed — image: references just work:

service:
  name: api
  namespace: default
  image: ghcr.io/myorg/api:1.4.0
  scale: 1
rune cast api.yaml

If credentials are missing or wrong you will see the inline failure block:

✗ ImageUnreachable
  pull access denied for ghcr.io/myorg/api, repository does not exist
  or may require 'docker login'
 
  image:   ghcr.io/myorg/api:1.4.0
  service: default/api
 
  rune logs api -n default --tail=50

That error text is the registry's response, surfaced directly. A credential rejection also names the registry pattern that supplied the credential, so you can tell a wrong credential from a missing one:

the credential configured for registry pattern "ghcr.io" was rejected;
renew it, or if this image is public scope the pattern to your private
repositories or mark it anonymous

If no credential was sent at all — nothing matched, a type: none entry matched, or the service opted out — the error says that instead:

registry requires credentials; no [[docker.registries]] entry matches
ghcr.io/myorg/api:1.4.0

3b. Scope a credential to a repository path

A registry pattern can carry a repository path, so a credential only claims the repositories it's for:

docker:
  registries:
    - name: ghcr-private
      registry: ghcr.io/myorg        # only ghcr.io/myorg/** uses the PAT
      auth:
        fromSecret: ghcr-credentials

ghcr.io/someoneelse/app then matches no configured entry — it pulls with no credential of yours, falling back to any ambient one (see below). Path comparison is segment-aware, so ghcr.io/myorg does not match ghcr.io/myorg-evil/app.

(Unmatched images can still pick up an ambient credential — the runed user's ~/.docker/config.json, or a GCE metadata service account. Those carry no pattern and are tried last; see docker.registries[].)

When several entries match one image, the most specific wins (deeper path first, then exact host over wildcard host, then longer pattern) — not declaration order. That's what makes a public carve-out possible on a host you otherwise need credentials for:

docker:
  registries:
    - name: ghcr-private            # many private repos across the host
      registry: ghcr.io
      auth:
        fromSecret: ghcr-credentials
 
    - name: ghcr-public             # ...but this one is public
      registry: ghcr.io/myorg/oss
      auth:
        type: none

:::note[No automatic anonymous retry] Rune does not silently retry a rejected pull without credentials. A public image at the same reference could stand in for the intended private one, so the downgrade has to be declared — either by scoping the pattern, by type: none, or by imagePullAnonymous on the service. A host-only pattern claims every image on that host, public repositories included: if the credential expires, those pulls fail with a hard denied rather than falling back to anonymous. :::

Per-service opt-out: imagePullAnonymous

A service author can force a credential-free pull without touching the server runefile:

service:
  name: web
  image: ghcr.io/myorg/oss-web:1.0.0
  imagePullAnonymous: true

No credential is ever sent for that image, so there is no failed attempt and no window in which an expired token can break it. Init steps and the --debug sidecar inherit the flag — they run the same image.

4. Rotate or revoke

Rotate by issuing a new PAT, then:

rune admin registry update \
  --name ghcr \
  --registry ghcr.io \
  --type basic \
  --username "$GITHUB_USER" \
  --password "$NEW_PAT"

Revoke at github.com/settings/tokens. Existing pulls in flight keep their credentials for the lifetime of that pull; new pulls fail immediately.

To remove a registry entirely:

rune admin registry remove ghcr

Per-service registry selection

Credentials are selected server-side, from docker.registries[]. A service cannot supply its own — scope the pattern instead, and use imagePullAnonymous for the one per-service control there is.

:::caution rune cast accepts registry: and imageRegistry: keys on a service spec. Nothing reads them. They validate, so a spec carrying them looks fine and pulls exactly as if they were absent. :::

Troubleshooting

SymptomCauseFix
ImageUnreachable with pull access deniedToken missing read:packages or package doesn't grant the user access.Regenerate PAT with the right scope; check Packages → Manage Actions access.
unauthorized: authentication requiredNo registry entry matches ghcr.io.rune admin registry list — add one if missing.
no [[docker.registries]] entry matches <image>The image needs credentials and none was sent. Either no pattern claimed it (often one narrowed past the repository), or a type: none entry matched, or the service set imagePullAnonymous: true.Rule out the last two first. Otherwise widen the pattern or add an entry.
the credential configured for registry pattern "X" was rejectedA credential was sent and refused. If rune admin registry list has no entry for X, it came from an ambient source and X is just the image host.Renew it. If the image is public, scope X to your private repos or set imagePullAnonymous: true.
A public image on GHCR fails with deniedA host-wide credential is being attached to it and has expired.Scope the pattern or mark the repository anonymous.
manifest unknownImage exists but tag doesn't, or org/name is wrong.docker pull from the same host to confirm.
Works locally, fails on the serverRuned wasn't restarted after a static config change, or the env var ($GHCR_PAT) isn't visible to runed.Use rune admin registry add instead of editing the runefile, or restart runed with the env exported.

See also