Skip to content
Latchkey LogoLatchkey home

Docker pull access denied repository does not exist in CI

Docker pull access denied repository does not exist in GitHub Actions is one refusal covering two different situations, and the registry declines to say which: either that repository is not there, or it is there and your job is not allowed to see it. Prove the name first, because a reference with no registry host in it goes to Docker Hub, which is not where your private image lives.

Runner log: a public pull succeeding, then a missing repository refused as access denied
The recorded run: the same anonymous client pulls alpine:3 and is then refused on a repository that does not exist. Nothing about the daemon changed between the two.
Diagram of the two situations behind one refusal and the order to check them in
Absent and unauthorized are the same answer on the wire, by design. The order below is the cheap check first, because the name is free to verify.

What this error means

A pull fails in a second or two, before any layer is downloaded. It arrives as a plain docker pull, as the implicit pull inside docker run, as a base image in a build stage, or as a service container the job never gets to use. The wording depends on the daemon. Older daemons name the image and suggest a login, in the shape "pull access denied for myorg/api, repository does not exist or may require 'docker login'". Docker 29 resolving through the containerd image store quotes the reference it was given and then says the authorization failed, with no login hint at all. The recorded run below is the second shape, produced by pulling a repository on Docker Hub that genuinely does not exist.

Actions log, pull step, Docker 29.7.2
Error response from daemon: failed to resolve reference "docker.io/latchkeyci/internal-api-does-not-exist-9f3c1a:1.4.2": pull access denied, repository does not exist or may require authorization: authorization failed: no basic auth credentials

Reproduced on a Latchkey runner

Run 2026-09-20·Runner latchkey-small·Exit code 1

Docker version 29.7.2, build a7dcaa6
--- control: a public image that exists
Status: Downloaded newer image for alpine:3
docker.io/library/alpine:3
--- docker pull docker.io/latchkeyci/internal-api-does-not-exist-9f3c1a:1.4.2
Error response from daemon: failed to resolve reference "docker.io/latchkeyci/internal-api-does-not-exist-9f3c1a:1.4.2": pull access denied, repository does not exist or may require authorization: authorization failed: no basic auth credentials
[latchkey-bash-wrapper] BEGIN sidecar POST (boot_wait=30s max_time=320s url=http://localhost/diagnose socket=/run/latchkey-self-heal/sock)
[latchkey-bash-wrapper] END sidecar POST ok (attempts=1 http=200)

The runner diagnosed the failure and did not retry it; this failure needs the fix below.

Why one message covers two problems

The registry API has separate codes for these cases: NAME_UNKNOWN for a name the registry does not know, UNAUTHORIZED for a client it could not authenticate, and DENIED for an authenticated client without permission. What reaches your log is one sentence that mentions both possibilities, because telling an anonymous client that a repository exists is itself information: it would let anybody enumerate an organization's private repositories by name.

The message is not vague by accident, and re-reading it will not narrow it down. You narrow it, in the order below, starting with the check that costs nothing.

CheckWhat it rules out
Print the full reference the step usedA typo, a missing tag and an empty variable
Confirm the registry host is in the referenceA private image being looked for on Docker Hub
Pull the same reference anonymously from your laptopA repository that is public and simply misnamed
Check the login step ran before the pull, in the same jobA credential that arrives after the thing that needed it
Check the token scope, not just the login resultA login that succeeded and still cannot read packages

Common causes

The reference has no registry host, so the pull went to Docker Hub

The most common cause in CI, and the easiest to miss because the reference looks complete. acme/api:1.4.2 is a Docker Hub reference; the image you meant is ghcr.io/acme/api:1.4.2. Docker fills in the default registry silently, the request lands in a namespace you do not own there, and this is the answer.

The image is private and the job never authenticated

The second reading of the same sentence. The pull was anonymous, so the registry answered as if the repository were absent. In our experience it shows up on the first workflow to pull a private base image, in a repository whose earlier jobs only ever pushed one.

The login succeeded and the token cannot read that package

A login result is not an authorization result. A GITHUB_TOKEN without packages: read, a token missing the read scope, or an org package that has not granted this repository access all produce a successful login and then this refusal. The give-away is "Login Succeeded" followed by a denied pull, which is what Dokploy/dokploy#5469 reports.

The repository really is not there yet

A first deploy, a renamed service, a tag nobody pushed, or an image a retention policy removed. People discount this reading because the pipeline pushed something yesterday, and it costs one check to rule out.

How to fix it

Print the reference, then pull it by hand

  1. Echo the full reference the step used, including the registry host and the tag.
  2. Pull that exact string from a machine with no credentials, to see whether it is public.
  3. If the anonymous pull works, the job is not authenticating the pull it thinks it is.
Terminal
echo "reference: [$IMAGE]"
docker logout ghcr.io
docker pull "$IMAGE"   # anonymous, on purpose

Put the registry host in every private reference

Write the host even when the default would be right, so a reader can tell where the image is meant to come from. It also survives a move to a runner whose daemon has a different default registry configured.

Terminal
# not this
docker pull acme/api:1.4.2
# this
docker pull ghcr.io/acme/api:1.4.2

Authenticate the thing that actually pulls

The runner daemon, a Kubernetes service account and a test harness each need their own credential. For Kubernetes, that is an image pull secret on the service account, not a docker login on the runner. For a Docker-based action, no login step can help, because the action image is pulled before your steps run: mirror that action image somewhere the job can already read.

Terminal
kubectl create secret docker-registry ghcr \
  --docker-server=ghcr.io \
  --docker-username="$GITHUB_ACTOR" \
  --docker-password="$GITHUB_TOKEN"

Check the scope, not the login

Add packages: read to the job permissions when pulling from GHCR with the built-in token, and grant the package access to the repository that needs it. Elsewhere, confirm the token can pull that repository: a push token is not always a pull token.

.github/workflows/ci.yml
permissions:
  contents: read
  packages: read

Docker 29 does not print the wording you searched for

Most write-ups about this error quote the older daemon: the image name, then "repository does not exist or may require 'docker login'", then "denied: requested access to the resource is denied". Our run on Docker 29.7.2 printed something else. It quoted the reference, said "pull access denied, repository does not exist or may require authorization", and ended with "authorization failed: no basic auth credentials", which is the resolver reporting that it had no credentials to offer rather than a suggestion that you log in.

That matters for a search and for any log check you have written. The two wordings share only "pull access denied", so a grep for "may require 'docker login'" stops matching after a runner image upgrade. The GitHub-hosted ubuntu-24.04 README listed Docker 28.0.4 when we read it on 2026-09-20 and our runner was on 29.7.2, so both are in circulation.

Terminal
# both wordings, one check
grep -E "pull access denied|repository does not exist" build.log

Log in where the pull happens, before it happens

A login step authenticates the runner daemon from that point in the job onward. It does nothing for an image pulled earlier, and nothing for a pull made by something else: a Docker-based action pulls its own image before your first step runs, and a Kubernetes node pulls with its own credentials.

For GitHub Container Registry inside the same repository the built-in token is enough, with packages: read on the job. A package private to another repository needs access granted to this repository, which is a setting on the package rather than in your workflow.

.github/workflows/ci.yml
permissions:
  contents: read
  packages: read
steps:
  - uses: docker/login-action@v4
    with:
      registry: ghcr.io
      username: ${{ github.actor }}
      password: ${{ secrets.GITHUB_TOKEN }}
  - run: docker pull ghcr.io/acme/api:1.4.2

What the runner does about it

Latchkey has no repair for this one, and the recorded run shows it: the wrapper posted the failure to the sidecar, the sidecar answered, and no repair followed, because none would help. A wrong name is wrong on every attempt, and a credential the job lacks does not appear on a second try.

There is one case where a retry is right, and it is worth separating: Docker Hub answers an over-limit anonymous client with a rate limit rather than an access error, and that one does clear on its own clock. If the reference is a public Docker Hub image that works from your laptop, read the Docker Hub pull rate limit page before you go looking for a credential you never needed.

How to prevent it

  • Reference private images with the registry host spelled out, everywhere.
  • Put the login step first in any job that pulls something private.
  • Give the job the narrowest package permission that lets the pull work.
  • Fail the pipeline early with an explicit check when a required image is missing.

Frequently asked questions

Why does it say the repository does not exist when I can see it in the registry?
Because a registry answers an unauthorized client and a missing repository the same way on purpose. The API has separate codes for unknown names, unauthenticated clients and denied access, and the client collapses them into one sentence so that nobody can map an organization's private repositories by pulling names. If you can see it in the web UI, treat it as an authorization problem.
Why did the message change after a runner image upgrade?
Because Docker 29 resolves references through the containerd image store and reports what the resolver saw. Our run on 29.7.2 printed "pull access denied, repository does not exist or may require authorization: authorization failed: no basic auth credentials", with no mention of docker login. The GitHub-hosted ubuntu-24.04 image README we read on 2026-09-20 lists Docker 28.0.4, which still prints the older wording.
My log shows Login Succeeded and then pull access denied. What is wrong?
The credential is valid and it is not allowed to read that repository, or it belongs to a different registry than the reference does. Check the host in the reference against the host you logged in to, then check the token scope: with GHCR and the built-in token you need packages: read on the job, plus package access granted to this repository.
Can a Docker Hub rate limit look like pull access denied?
It can for anonymous pulls, which is why the check order matters: if the image is public and pulls from your laptop, suspect the limit rather than a credential. Docker Hub counts anonymous pulls per address, and hosted runners share one, so the refusal can arrive for something somebody else did.

Related guides

References

Latchkey runners keep a Docker layer cache between jobs, so the base image is already there. Start free → 30-day trial · No credit card