Your pod is stuck because the node can't pull the image. Here's how to read the events, find the real reason, and fix each one fast.
You applied a deploy, ran kubectl get pods, and there it is: a pod sitting in ImagePullBackOff. Nothing crashed. No logs to read, because the container never started. The node tried to pull the image, failed, and Kubernetes is now backing off and retrying with a growing delay. ErrImagePull is the first failed attempt; ImagePullBackOff is what you get once the kubelet decides to slow down the retries.
The status tells you almost nothing on its own. The reason lives in the pod events, and once you read those, the fix is usually a two-minute job. The mistake people make is guessing. Don't guess. Read the events first.
Every diagnosis starts here:
kubectl describe pod web-7d9c8b5f4-xq2mn
Scroll to the bottom, to the Events section:
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 2m default Successfully assigned prod/web-7d9c8b5f4-xq2mn to node-3
Normal Pulling 1m (x4 over 2m) kubelet Pulling image "myco/web:v1.4.2"
Warning Failed 1m (x4 over 2m) kubelet Failed to pull image "myco/web:v1.4.2": rpc error: code = NotFound desc = manifest for myco/web:v1.4.2 not found
Warning Failed 1m (x4 over 2m) kubelet Error: ErrImagePull
Normal BackOff 30s (x6 over 2m) kubelet Back-off pulling image "myco/web:v1.4.2"
Warning Failed 30s (x6 over 2m) kubelet Error: ImagePullBackOff
That manifest ... not found line is the whole answer. The image name resolved, the registry answered, but that tag doesn't exist. Different reasons give different messages, and the message is what you fix. If you skip describe and start editing YAML at random, you'll waste an afternoon.
Typo in the image name or tag. The most common one, and the least glamorous. manifest not found or pull access denied when the repo path is wrong. Check for a missing org prefix (web instead of myco/web), a fat-fingered tag, or lastest instead of latest. Confirm the exact reference works from your laptop:
docker pull myco/web:v1.4.2
If that fails the same way, Kubernetes was never the problem.
The tag genuinely doesn't exist. Someone bumped the deploy to v1.4.2 before CI pushed the image, or the tag got garbage-collected. List what the registry actually has:
crane ls myco/web
# or
skopeo list-tags docker://myco/web
Point the deploy at a tag that exists. And this is the moment to say it: stop deploying :latest. A floating tag makes this failure intermittent and unreproducible, which is the worst kind of failure. Pin a digest or an immutable tag.
Private registry with no imagePullSecret. The message is blunt: pull access denied or authentication required. The node has no credentials for that registry. This is the fix people forget most, so here it is in full. Create a docker-registry secret:
kubectl create secret docker-registry regcred \
--docker-server=registry.myco.io \
--docker-username=deploy-bot \
--docker-password="$REGISTRY_TOKEN" \
--namespace=prod
Then reference it in the pod spec:
spec:
imagePullSecrets:
- name: regcred
containers:
- name: web
image: registry.myco.io/web:v1.4.2
The secret is namespaced. If the pod runs in prod, the secret has to live in prod too. A regcred sitting in default does nothing for a pod in another namespace, and this trips up more people than it should.
Expired or rotated credentials. The secret exists, the pod references it, and it still fails with authentication required. The token behind that secret expired or got rotated. Registry auth secrets are static blobs; they don't refresh themselves. Decode it and check what's in there:
kubectl get secret regcred -n prod \
-o jsonpath='{.data.\.dockerconfigjson}' | base64 -d
If the token's stale, recreate the secret. This is a strong argument for wiring registry auth through something that rotates on its own, like the ECR credential helper or a cloud workload identity, instead of a hand-baked secret that quietly dies in ninety days.
Docker Hub rate limits. If you pull public images from Docker Hub on anonymous or free-tier auth, you'll hit 429 Too Many Requests: toomanyrequests. A busy cluster with a NAT gateway shares one source IP, so all your nodes count as one anonymous user and burn the quota fast. Authenticate your pulls even for public images, or mirror what you depend on into a registry you control. Pull-through caches exist for exactly this.
Wrong architecture. The pull "succeeds" but the pod won't run, or you see no matching manifest for linux/arm64. You built an amd64 image and scheduled it onto Graviton or Apple-silicon nodes, or the reverse. Check the manifest:
docker manifest inspect myco/web:v1.4.2 | grep architecture
Build multi-arch with docker buildx and push a manifest list. Then the same tag works on both node types.
Registry unreachable or air-gapped. The message reads dial tcp: i/o timeout or no such host. The node can't route to the registry at all. In an air-gapped cluster the fix is a reachable internal mirror. Otherwise it's DNS, a firewall rule, a proxy the kubelet needs, or a private endpoint that isn't wired up. Exec onto a debug pod on the same node and try to reach the registry directly before you touch any YAML.
imagePullPolicy confusion. With imagePullPolicy: IfNotPresent, the node reuses a cached image and never notices you pushed a new build under the same tag. People read that as "the pull failed" when the real problem is a stale cache. Mutable tags plus IfNotPresent is a trap. Use immutable tags and this class of confusion disappears. If you must reproduce a fresh pull, set Always and confirm the registry side is actually correct.
The ImagePullBackOff string is a symptom. The Failed event above it names the cause: not found, access denied, toomanyrequests, no such host. Match the message to the cause and you're done in minutes. For the wider set of pod states around this one, our Kubernetes troubleshooting guide covers what comes after the image finally pulls.
Pin immutable tags or digests, never :latest. Authenticate every registry pull, public ones included, so a Docker Hub rate limit never becomes a production incident. And always run kubectl describe pod before you change a single line of YAML, because the event log has already told you the answer.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
Our best engineer quit citing on-call. We rebuilt the whole thing: saner rotations, runbooks that actually help at 3am, and escalation that doesn't punish asking for help.
We ripped every client secret out of our CI pipelines by pointing Azure federated credentials at GitHub's OIDC issuer. Here's the exact setup and the claims that trip people up.
Explore more articles in this category
Every CI/CD platform claims to be fast and easy. The real differences are in pricing, self-hosting, and where each one falls apart at scale. This is the map.
Woodpecker forked Drone when the license changed. Here's how the two compare for small teams and homelabs that just want simple container-native CI.
Buildkite runs the control plane and lets you own the compute; Actions keeps everything close to your repo. Here's how they actually differ once you scale.
Evergreen posts worth revisiting.