GitLab CI Error Guide: 'Preparation failed: failed to pull image' Docker Image Pull Errors
Fix GitLab CI 'failed to pull image' errors: Docker Hub rate limits, missing tags, private registry auth with DOCKER_AUTH_CONFIG, pull_policy mismatches, and TLS trust.
- #gitlab-cicd
- #troubleshooting
- #errors
- #docker
Overview
When a GitLab job fails before your script: ever runs, with ERROR: Preparation failed: failed to pull image, the runner could not fetch the container image named in your job’s image: keyword. The docker or kubernetes executor pulls that image first, then starts your job inside it — so if the pull fails, the job aborts in the preparation phase and you never see a single line of your own logs.
The message always names the image and the pull policies the runner tried, then appends the daemon’s reason. That tail is where the real diagnosis lives: a 429 toomanyrequests is a Docker Hub rate limit, a manifest unknown is a bad tag, a pull access denied is missing registry auth, and an x509 error is an untrusted certificate. The error is consistent; the cause behind the colon varies.
The most common form, an anonymous pull throttled by Docker Hub on a shared runner, looks like this:
ERROR: Preparation failed: failed to pull image 'node:20-alpine' with specified policies [always]: Error response from daemon: toomanyrequests: You have reached your pull rate limit. You may increase the limit by authenticating and upgrading: https://www.docker.com/increase-rate-limit (manager.go:237:0s)
A private registry without credentials produces a different, equally literal tail — note it names the repository and suggests docker login:
ERROR: Preparation failed: failed to pull image 'registry.example.com/private/app:1.4.0' with specified policies [always]: Error response from daemon: pull access denied for registry.example.com/private/app, repository does not exist or may require 'docker login' (manager.go:237:0s)
This is a runner/registry/credentials problem, not a flaw in your script: — the job dies during image preparation, before your commands are ever scheduled.
Symptoms
- A job fails almost immediately with
ERROR: Preparation failed: failed to pull image '...'and no output from your ownscript:. - The failure is intermittent on shared runners (passes sometimes, fails with
toomanyrequestsat peak hours). - A job that worked yesterday breaks after someone bumped the tag in
image:or pushed to a new private registry. - The error tail varies:
manifest unknown,not found,pull access denied,x509: certificate signed by unknown authority, or a connection timeout. - The same
docker pullworks from your laptop but fails on the runner host.
glab ci status
Pipeline #91044 (failed) for branch feature/upgrade-node
test failed ERROR: Preparation failed: failed to pull image 'node:20-alpine'...
# Reproduce the pull from the runner host itself
sudo docker pull node:20-alpine
Error response from daemon: toomanyrequests: You have reached your pull rate limit.
A pull that fails the same way by hand on the runner host confirms the problem is the image fetch, not your pipeline YAML.
Common Root Causes
1. Docker Hub anonymous pull rate limit (429 toomanyrequests)
Docker Hub enforces a pull rate limit on anonymous (unauthenticated) requests, counted per source IP. On a shared or NAT’d runner fleet, many jobs pull from the same egress IP, so the limit is reached collectively even though your single job pulls once. The pull is rejected with toomanyrequests.
# Pulls from Docker Hub on this host are unauthenticated unless config.json has creds
sudo cat ~/.docker/config.json 2>/dev/null | jq '.auths | keys'
null
ERROR: Preparation failed: failed to pull image 'node:20-alpine' with specified policies [always]: Error response from daemon: toomanyrequests: You have reached your pull rate limit. You may increase the limit by authenticating and upgrading: https://www.docker.com/increase-rate-limit (manager.go:237:0s)
No auths entry for https://index.docker.io/v1/ means anonymous pulls. Authenticate Docker Hub via DOCKER_AUTH_CONFIG (see #3), or mirror through a registry cache so the runner is not pulling from Hub on every job.
2. Image name or tag typo — the tag does not exist
The runner asks the registry for the exact name:tag in image:. A typo in the repository, a tag that was never pushed, or a deleted tag returns manifest unknown / not found. The registry is reachable and authenticated; it simply has no such manifest.
# .gitlab-ci.yml — 'latst' is a typo for 'latest'
test:
stage: test
image: myregistry.example.com/app:latst
script:
- run-tests
ERROR: Preparation failed: failed to pull image 'myregistry.example.com/app:latst' with specified policies [always]: Error response from daemon: manifest for myregistry.example.com/app:latst not found: manifest unknown (manager.go:237:0s)
Fix the tag. Confirm which tags actually exist before guessing:
sudo docker pull myregistry.example.com/app:latst
# or list tags from the registry API
curl -s "https://myregistry.example.com/v2/app/tags/list" | jq '.tags'
["1.4.0", "1.4.1", "latest"]
3. Private registry needs auth — DOCKER_AUTH_CONFIG or image_pull_secrets missing
Pulling from a private registry requires credentials the runner does not have by default. For the docker executor, GitLab reads them from the DOCKER_AUTH_CONFIG CI/CD variable (a JSON blob in ~/.docker/config.json format). Without it, the daemon returns pull access denied. The Kubernetes executor instead needs an image_pull_secrets entry pointing at a registry Secret.
# Is DOCKER_AUTH_CONFIG defined for the project/group?
glab variable list | grep DOCKER_AUTH_CONFIG
(no output)
ERROR: Preparation failed: failed to pull image 'registry.example.com/private/app:1.4.0' with specified policies [always]: Error response from daemon: pull access denied for registry.example.com/private/app, repository does not exist or may require 'docker login' (manager.go:237:0s)
Add a masked DOCKER_AUTH_CONFIG variable. The base64 value is the auth token for the registry, exactly as docker login would write it:
{ "auths": { "registry.example.com": { "auth": "BASE64_OF_user:password" } } }
For the Kubernetes executor, create a kubernetes.io/dockerconfigjson Secret and reference it in config.toml under [runners.kubernetes] image_pull_secrets.
4. Registry or network unreachable from the runner (timeout / TLS / proxy)
The runner host may have no route to the registry: a firewall blocks egress, a corporate proxy is required but not configured, or DNS does not resolve the registry host. The pull stalls and times out, or fails on the TLS handshake, before any auth is even attempted.
sudo docker pull myregistry.internal:5000/app:1.4.0
curl -sS -o /dev/null -w "%{http_code}\n" https://myregistry.internal:5000/v2/
Error response from daemon: Get "https://myregistry.internal:5000/v2/": dial tcp 10.20.0.9:5000: i/o timeout
ERROR: Preparation failed: failed to pull image 'myregistry.internal:5000/app:1.4.0' with specified policies [always]: Error response from daemon: Get "https://myregistry.internal:5000/v2/": dial tcp 10.20.0.9:5000: i/o timeout (manager.go:237:0s)
Open the egress path or set HTTP_PROXY/HTTPS_PROXY/NO_PROXY for the Docker daemon (and the runner). A registry the runner cannot reach can never be pulled regardless of credentials.
5. Pull policy mismatch (if-not-present has no cache, or always needs creds)
The runner’s config.toml sets [runners.docker] pull_policy. With if-not-present, the runner only pulls when the image is not already cached locally — so a fresh host with no cached image and an unreachable/unauthenticated registry fails. With always, it pulls every run, which fails immediately if creds are missing. The policies the runner tried are echoed in the error as with specified policies [...].
grep -A4 '\[runners.docker\]' /etc/gitlab-runner/config.toml | grep pull_policy
pull_policy = ["if-not-present"]
ERROR: Preparation failed: failed to pull image 'internal/tool:dev' with specified policies [if-not-present]: Error response from daemon: pull access denied for internal/tool, repository does not exist or may require 'docker login' (manager.go:237:0s)
Align the policy with reality: use always (with valid DOCKER_AUTH_CONFIG) for images that change, or pre-seed the image on the host if you intend if-not-present. You can also allow multiple policies, e.g. ["always", "if-not-present"], so a transient pull failure can fall back to a cached copy.
6. Untrusted certificate or platform/arch mismatch (no matching manifest)
A self-signed or internal-CA registry whose certificate the runner host does not trust fails the TLS handshake with x509: certificate signed by unknown authority. A related class of failure is an architecture mismatch: pulling an amd64-only image onto an arm64 runner (or vice versa) returns no matching manifest for linux/arm64/v8 in the manifest list entries.
# Trust check against the internal registry
openssl s_client -connect registry.internal:5000 -servername registry.internal </dev/null 2>/dev/null | grep -i verify
# Confirm the runner host architecture
uname -m
verify error:num=19:self-signed certificate in certificate chain
aarch64
ERROR: Preparation failed: failed to pull image 'registry.internal:5000/app:1.4.0' with specified policies [always]: Error response from daemon: Get "https://registry.internal:5000/v2/": x509: certificate signed by unknown authority (manager.go:237:0s)
Install the registry CA into the runner host’s trust store (and Docker’s certs.d), or rebuild/pull a multi-arch (linux/amd64 + linux/arm64) image so a manifest exists for the runner’s platform.
Diagnostic Workflow
Step 1: Read the full error tail and note the image, policies, and reason
glab ci status
The text after the final colon is the diagnosis. toomanyrequests is a Hub rate limit (#1); manifest unknown / not found is a bad tag (#2); pull access denied is missing auth (#3); i/o timeout is unreachable (#4); x509 is an untrusted cert (#6); no matching manifest is an arch mismatch (#6). The bracketed [...] lists the pull_policy the runner used (#5).
Step 2: Reproduce the pull on the runner host by hand
sudo docker pull node:20-alpine
sudo docker pull myregistry.example.com/app:1.4.0
If the same docker pull fails identically on the runner host, the problem is the image fetch, not your pipeline. The daemon’s error here is usually cleaner than the wrapped GitLab message.
Step 3: Confirm the tag exists and the image name is correct
curl -s "https://myregistry.example.com/v2/app/tags/list" | jq '.tags'
grep -E 'image:' .gitlab-ci.yml
Compare the image: value in your YAML to the real tag list. A manifest unknown almost always means the tag in image: was never pushed or is misspelled.
Step 4: Verify registry credentials and trust
glab variable list | grep -E 'DOCKER_AUTH_CONFIG'
sudo cat ~/.docker/config.json | jq '.auths | keys'
openssl s_client -connect registry.internal:5000 -servername registry.internal </dev/null 2>/dev/null | grep -i verify
A missing DOCKER_AUTH_CONFIG (or absent auths entry) explains pull access denied; a verify error on the cert explains x509. For Kubernetes executors, check image_pull_secrets in config.toml and that the referenced Secret exists.
Step 5: Check the runner’s pull_policy and network path
grep -A6 '\[runners.docker\]' /etc/gitlab-runner/config.toml
curl -sS -o /dev/null -w "%{http_code}\n" https://registry.example.com/v2/
A pull_policy of if-not-present on a host with no cached image, or an unreachable /v2/ endpoint, both surface here. Adjust the policy or open the egress path, then retry the pipeline.
Example Root Cause Analysis
A team’s pipelines on gitlab.example.com start failing intermittently in the afternoon. The test job dies in preparation roughly half the time, always on the same image.
The job log names the problem:
ERROR: Preparation failed: failed to pull image 'python:3.12-slim' with specified policies [always]: Error response from daemon: toomanyrequests: You have reached your pull rate limit. You may increase the limit by authenticating and upgrading: https://www.docker.com/increase-rate-limit (manager.go:237:0s)
The image name and tag are correct, and the pull succeeds in the morning — so it is not a typo or a missing manifest. Reproducing on the runner host confirms the rate limit, and checking the Docker config shows why:
sudo docker pull python:3.12-slim
sudo cat ~/.docker/config.json | jq '.auths | keys'
Error response from daemon: toomanyrequests: You have reached your pull rate limit.
null
The runner has no Docker Hub credentials, so every pull is anonymous. Six runners share one NAT egress IP, so under afternoon load they collectively blow through the anonymous limit and Hub returns 429.
Fix: authenticate the runner’s Hub pulls with a DOCKER_AUTH_CONFIG CI/CD variable so the limit is counted against an account, not the shared IP.
# Build the auth token and set the masked variable
AUTH=$(printf '%s' 'hubuser:hubtoken' | base64)
glab variable set DOCKER_AUTH_CONFIG --masked \
--value "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$AUTH\"}}}"
✓ Created variable DOCKER_AUTH_CONFIG
On the next run the runner authenticates to Docker Hub before pulling, the 429 disappears, and the test job moves past preparation into your script:. Mirroring Hub through a pull-through cache would further insulate the fleet from the limit.
Prevention Best Practices
- Authenticate Docker Hub pulls on every runner via
DOCKER_AUTH_CONFIGso shared-IP fleets are never throttled as anonymous, and consider a pull-through registry mirror for hot base images. - Pin
image:to immutable tags or digests you know are pushed, and avoidlatestso a re-tag elsewhere cannot silently break preparation withmanifest unknown. - Store private-registry credentials as a masked, protected
DOCKER_AUTH_CONFIGvariable (or a Kubernetesimage_pull_secretsSecret) and rotate them deliberately, re-testing a pull after each rotation. - Set
pull_policyto match how your images change —["always"]with valid creds for moving tags, or["always", "if-not-present"]so a transient pull failure can fall back to a cached copy. - Install internal-registry CAs into every runner host’s trust store at provisioning time, and build multi-arch images so a manifest exists for each runner platform you operate.
- For fast triage of a preparation-phase failure, the free incident assistant can read the error tail and point at the likely image-pull cause. More pipeline fixes live in the GitLab CI/CD guides.
Quick Command Reference
# Read the full error tail and pipeline state
glab ci status
# Reproduce the pull on the runner host by hand
sudo docker pull node:20-alpine
sudo docker pull myregistry.example.com/app:1.4.0
# Confirm the tag actually exists in the registry
curl -s "https://myregistry.example.com/v2/app/tags/list" | jq '.tags'
grep -E 'image:' .gitlab-ci.yml
# Check Docker Hub / private-registry credentials
glab variable list | grep DOCKER_AUTH_CONFIG
sudo cat ~/.docker/config.json | jq '.auths | keys'
# Inspect the runner's pull policy and registry reachability
grep -A6 '\[runners.docker\]' /etc/gitlab-runner/config.toml
curl -sS -o /dev/null -w "%{http_code}\n" https://registry.example.com/v2/
# Verify registry TLS trust and host architecture
openssl s_client -connect registry.internal:5000 -servername registry.internal </dev/null 2>/dev/null | grep -i verify
uname -m
# Set a masked Docker Hub auth variable
AUTH=$(printf '%s' 'hubuser:hubtoken' | base64)
glab variable set DOCKER_AUTH_CONFIG --masked \
--value "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$AUTH\"}}}"
Conclusion
ERROR: Preparation failed: failed to pull image is GitLab telling you the runner could not fetch your job’s image: before the job could start. The reason after the final colon points straight at one of these root causes:
- A Docker Hub anonymous pull rate limit (
429 toomanyrequests) hit by a shared-IP runner fleet. - A typo or non-existent tag in
image:, returningmanifest unknown/not found. - A private registry with missing or wrong credentials (
DOCKER_AUTH_CONFIGorimage_pull_secrets), returningpull access denied. - A registry the runner cannot reach — firewall, proxy, or DNS — failing with a timeout or TLS error.
- A
pull_policymismatch:if-not-presentwith no cached image, oralwayswithout valid credentials. - An untrusted registry certificate (
x509) or a platform/arch withno matching manifest.
Read the error tail first — it names the image, the policies tried, and the daemon’s exact reason — then reproduce the pull on the runner host, and the fix is almost always authenticating the registry, correcting the tag, or opening the path the runner needs to reach it.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.