~blogread-the-error-inside-out

Docker "failed to resolve reference": read the error inside out

2025-07-08 · 4 min read · debugging · docker · dns · linux

Most error messages are written outside-in: the component that gave up speaks first, and the thing that broke is buried in the last clause. We read them the same way we read English, left to right, and we start debugging the loudest noun in the first line. That noun is usually the victim.

Two failures taught me to read the other direction. Both cost me time I would have kept by starting at the innermost cause.

Case 1: the registry error that was never about the registry

A deployment could not pull an image:

failed to resolve reference "registry.internal.example.com/web:staging"
dial tcp: lookup registry.internal.example.com on 10.0.0.1:53:
  read udp 10.0.0.55:50332->10.0.0.1:53: read: connection refused

Read the first line and the obvious suspects are the registry, the image tag, and my credentials. I nearly went and checked whether the tag had been pushed.

Read the last clause and there is no ambiguity at all:

read udp 10.0.0.55:50332 -> 10.0.0.1:53: read: connection refused
                                    ^^     ^^^^^^^^^^^^^^^^^^
                                    DNS    nothing is listening

Port 53 is DNS. connection refused means a machine answered and declined, which is what a host does when no service is bound to that port. So: this machine asked its configured resolver for a name, and the resolver was not running. Docker never resolved the hostname, never opened a TCP connection, never presented a credential. Everything the first line names had not happened yet.

This is the registry row from the deploy pipeline writeup, and the cause was mundane. The host was configured to use the LAN gateway as its DNS server, and that gateway was not running a DNS service that accepted queries. Any name lookup on that box would fail. It surfaced as a Docker problem because a deploy happened to be the next thing that needed a name.

Diagnosis is three commands, in order, each one testing a smaller claim than the last:

cat /etc/resolv.conf                 # who am I asking?
dig @10.0.0.1 registry.internal.example.com   # does that server answer at all?
getent hosts registry.internal.example.com    # does the system resolve it?

If the second one refuses, nothing above it can work, and no amount of docker login will help.

Fixing the host resolver is the real repair. There is a Docker-level fallback in /etc/docker/daemon.json:

{
  "dns": ["1.1.1.1", "8.8.8.8"]
}

Use public resolvers only if the names you need are public. Mine were internal, so pointing at 1.1.1.1 would have swapped connection refused for NXDOMAIN and left me debugging a different symptom of the same disease.

There is a useful ordering underneath this. A container image pull is a stack of preconditions, and each one can only fail once the one below it succeeded:

6. manifest / layers available
5. repository permissions
4. registry authentication
3. TLS handshake
2. TCP reachability
1. DNS resolution        <- failed here

My instinct was to start at 4, because “pull failed” pattern-matches to “credentials”. Starting at 1 costs ten seconds and rules out five layers.

Case 2: the port was in use, by nothing I could see

Different day. A container would not start because its host port was taken. docker ps showed nothing on that port. No container was publishing it. I restarted the daemon, which achieved what restarting the daemon usually achieves.

Docker was right, and Docker was not the right place to look. Port bindings are a property of the host’s network stack, not of Docker’s bookkeeping. Any process on the machine can hold one:

sudo ss -ltnp | grep ':5001'
sudo lsof -iTCP:5001 -sTCP:LISTEN -n -P

A monitoring agent, installed as a host service and completely outside Docker, was listening on 127.0.0.1:5001. It had been there for months. Nothing had needed that port until this deployment.

ps -fp <pid>                                   # what is this process?
systemctl list-units --type=service | grep -i <name>   # what installed it?

Then the same principle as before, from the inside out: the kernel owns the socket table, Docker only asks for a binding. Ask the kernel.

The habit

When an error has multiple clauses, find the innermost one first. Some practical markers of where the real information sits:

A syscall.Errno, an errno value, or a bare number in a detail field is the kernel speaking, and the kernel does not speculate. A port number tells you which protocol failed regardless of what the application layer claims it was doing. connection refused and timed out are different diagnoses: refused means something answered, timed out usually means a firewall or a dead host. And a hostname in an error means name resolution was at least attempted, which narrows the timeline of what already worked.

The general rule that came out of both of these:

The component that reports an error is rarely the component that has the problem. It is the first one that could not continue.

That gap is where the hours go. Read the message backwards, find the smallest claim it makes, and test that claim on its own. If it fails, everything above it is noise, including the part in bold at the top.