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.


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.
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 credentialsReproduced on a Latchkey runner
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.
| Check | What it rules out |
|---|---|
| Print the full reference the step used | A typo, a missing tag and an empty variable |
| Confirm the registry host is in the reference | A private image being looked for on Docker Hub |
| Pull the same reference anonymously from your laptop | A repository that is public and simply misnamed |
| Check the login step ran before the pull, in the same job | A credential that arrives after the thing that needed it |
| Check the token scope, not just the login result | A 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
- Echo the full reference the step used, including the registry host and the tag.
- Pull that exact string from a machine with no credentials, to see whether it is public.
- If the anonymous pull works, the job is not authenticating the pull it thinks it is.
echo "reference: [$IMAGE]"
docker logout ghcr.io
docker pull "$IMAGE" # anonymous, on purposePut 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.
# not this
docker pull acme/api:1.4.2
# this
docker pull ghcr.io/acme/api:1.4.2Authenticate 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.
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.
permissions:
contents: read
packages: readDocker 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.
# both wordings, one check
grep -E "pull access denied|repository does not exist" build.logLog 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.
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.2What 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?
Why did the message change after a runner image upgrade?
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?
packages: read on the job, plus package access granted to this repository.Can a Docker Hub rate limit look like pull access denied?
Related guides
References
- Distribution registry API: NAME_UNKNOWN, UNAUTHORIZED and DENIED
- Docker docs: docker login and where credentials are stored
- Docker docs: docker image pull and the default registry
- getodk/central#2255: pull access denied for minio/minio in an e2e test job
- Docker documentation
- Docker build cache
- GitHub Actions documentation