EAI_AGAIN and could not resolve host in GitHub Actions
EAI_AGAIN in GitHub Actions is the resolver saying try again: the lookup did not fail, it never finished, so the process died before it opened a connection. Separate it from ENOTFOUND, which means the name does not exist, and then fix the resolver the failing process was handed rather than the one you can see.


What this error means
A step stops before any transfer starts, and the wording depends on which library asked. Node prints "getaddrinfo EAI_AGAIN registry.npmjs.org" with a code of EAI_AGAIN and no stack of yours in it. curl prints "curl: (6) Could not resolve host" and exits 6, which the curl documentation defines as "could not resolve host, the given remote host was not resolved". Anything going through glibc, which is apt, git, wget and most of a base image, prints "Temporary failure in name resolution". They are one failure: a name went to the resolver and no usable answer came back. The near-identical ENOTFOUND is a different answer, and the difference decides the fix. The run below breaks the resolver inside a mount namespace, so the step sees a nameserver that cannot answer while the rest of the machine keeps working.
resolver: nameserver 10.255.255.1
node dns.lookup: getaddrinfo EAI_AGAIN registry.npmjs.org
curl: (6) Could not resolve host: registry.npmjs.orgReproduced on a Latchkey runner
attempt 1
resolver: nameserver 10.255.255.1
node dns.lookup: getaddrinfo EAI_AGAIN registry.npmjs.org
curl: (6) Could not resolve host: registry.npmjs.org
[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)
attempt 2
resolver: nameserver 127.0.0.53
node dns.lookup: registry.npmjs.org is 104.16.8.34
curl: registry.npmjs.org answeredRetried with exponential backoff after a generic DNS lookup failure
Three messages, two answers, one question
Every one of these comes from the same call. What differs is whether the resolver said "not yet" or "no", and every useful decision follows from that.
| What you see | What the resolver said | What to do |
|---|---|---|
EAI_AGAIN | No answer arrived in time | Retry, then look at the resolver |
| Temporary failure in name resolution | The same thing, from glibc | Retry, then look at the resolver |
curl: (6) Could not resolve host | Either, curl does not distinguish | Check the name, then retry |
ENOTFOUND, NXDOMAIN | That name does not exist | Fix the name or the search domain |
Common causes
The resolver did not answer in time
The ordinary case, and the one a retry fixes. A shared resolver under load, a brief packet loss, or a runner whose network was still settling all produce a lookup that times out rather than one that fails. It is the most common cause and the least interesting, which is why it is worth ruling in quickly and moving on.
You checked the name from the wrong place
The wasted fix on this failure. The debug step runs on the runner, resolves the name, and proves nothing about the build container that failed, whose /etc/resolv.conf may be empty, may point at an address that is unreachable from the build network, or may not exist. In our experience a DNS failure that only happens inside docker build has never once been fixed by a check run outside it.
The container was never given a resolver
A build with a custom network, a step running with networking disabled, or a daemon with no DNS configuration produces a container that cannot look anything up at all. This one fails instantly and identically on every attempt, which is how you tell it from a blip: a transient failure is intermittent, and a missing resolver never is.
The name is private, misspelled or gone
A service alias that only resolves on a Docker network, an internal registry that needs a VPN or a private zone, or a hostname with a typo in a secret. The resolver is working perfectly and answering the question you asked. This is the one where retrying is pure cost.
How to fix it
Retry, with a limit
- Give the failing command its own retry rather than re-running the whole job.
- Use the tool's own retry where it has one, because it retries the request rather than the process.
- Cap it at three attempts, so a name that does not exist fails quickly instead of slowly.
curl --retry 3 --retry-all-errors --retry-delay 3 -fsSL https://get.example.com/install.sh | bash
# or, for anything without its own retry
for i in 1 2 3; do getent hosts registry.npmjs.org && break; sleep 3; doneCheck from inside the context that failed
Move the check into the build stage, the container job or the service container that produced the error. The answer is usually immediate: an empty resolver file, a nameserver address the build network cannot reach, or a name that resolves on the runner and nowhere else.
# a build stage that reports its own DNS before it needs it
RUN cat /etc/resolv.conf; getent hosts deb.debian.org || echo "no DNS in this stage"Give the container a resolver that answers
Set DNS for the daemon when every build needs it, or for one container when only one does. Prefer configuring the daemon on a runner you own, because a per-command flag is one more thing to remember in every workflow that builds.
# /etc/docker/daemon.json
{ "dns": ["1.1.1.1", "8.8.8.8"] }
# or for a single job container
jobs:
test:
container:
image: node:22
options: --dns 1.1.1.1 --dns 8.8.8.8Fix the name rather than the network
When the answer is that the name does not exist, no amount of resolver configuration helps. Service aliases resolve only for containers on the same network, so a job on the runner host cannot reach one by name. A private zone needs the runner inside it, or a public record, or an entry in the hosts file for the length of the job.
echo "10.0.12.4 registry.internal.example.com" | sudo tee -a /etc/hosts
getent hosts registry.internal.example.comThe resolver that failed is not always the one you can see
This is the part that makes the failure feel irrational. You add a debug step, it resolves the name perfectly, and the build keeps failing. That is because the resolver belongs to the process, not to the machine: a docker build stage, a container: job, a service container and the runner shell can each have a different /etc/resolv.conf, and a build step running with networking disabled has none at all.
Check from inside the thing that failed. If the failing line is under a build stage heading, the check belongs in the same stage; if it is inside a service container, use that container.
# inside the failing build stage, not on the runner
RUN cat /etc/resolv.conf && getent hosts registry.npmjs.org
# inside a service container
docker exec "$(docker ps -q -f name=redis)" getent hosts redisChecks that take two seconds
Three commands separate every cause on this page. The first shows which nameserver the process was given, the second asks that nameserver, and the third asks a public one directly. If the first two fail and the third works, the resolver is the problem rather than the network.
Add them to a failure-only step and the next occurrence arrives already diagnosed, which matters on a failure that does not reproduce on demand.
- name: DNS state
if: failure()
run: |
cat /etc/resolv.conf
getent hosts registry.npmjs.org || echo "no answer from the configured resolver"
dig +short @1.1.1.1 registry.npmjs.org || trueWhat the runner does about it
Latchkey detects all three wordings with one pattern, NETWORK_DNS_GENERIC, at confidence 0.90, and its plan is a retry with backoff: three attempts starting at 3 seconds. The library is explicit about why one entry covers Node, glibc and curl together: "EAI_AGAIN is again later; ENOTFOUND is no record. Both are typically transient on shared CI runners and respond to retry."
One thing the table above and the engine do differently, and it is worth knowing which way: that one entry matches ENOTFOUND as well as EAI_AGAIN, so a name which will never resolve gets retried too. That costs one backoff, three seconds, before the real failure surfaces, which is a fair price for covering the case that does clear.
On the recorded run the step was handed a nameserver that could not answer, both the Node lookup and the curl request failed, and the retried step resolved the name and connected. That is the honest limit of what a retry can do here: it fixes a resolver that came back, and it cannot fix a container that was never given one.
How to prevent it
- Record
/etc/resolv.confand one lookup in a failure-only step, from inside the failing context. - Configure DNS on the daemon once, rather than per container in every workflow.
- Use each tool's own retry flags, which retry the request instead of the process.
- Keep private hostnames out of workflows that run on machines outside the private zone.