# Docker pull access denied repository does not exist in CI

> Fix Docker pull access denied repository does not exist in GitHub Actions: tell a missing name from a private image, and read the newer wording.

Source: https://latchkey.dev/learn/docker/docker-pull-access-denied-repository-does-not-exist  
Updated: 2026-09-20

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.

```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
```

## 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
```

## 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.

## 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 |

> A reference that fails the first check is a different page: [Docker invalid reference format in CI](/learn/docker/docker-invalid-reference-format-in-ci) covers the names Docker rejects before it ever asks a registry.

## 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](/learn/failures/docker-hub-pull-rate-limit-in-ci) before you go looking for a credential you never needed.

## FAQ

### 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.

## References

- [Distribution registry API: NAME_UNKNOWN, UNAUTHORIZED and DENIED](https://distribution.github.io/distribution/spec/api/)
- [Docker docs: docker login and where credentials are stored](https://docs.docker.com/reference/cli/docker/login/)
- [Docker docs: docker image pull and the default registry](https://docs.docker.com/reference/cli/docker/image/pull/)
- [getodk/central#2255: pull access denied for minio/minio in an e2e test job](https://github.com/getodk/central/issues/2255)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
