Fix Puppeteer net::ERR_NAME_NOT_RESOLVED (6 Causes)

Vitalii Holben Published Reading10 min

Your Puppeteer script works on your local machine. You deploy it to Docker or Lambda, and Chrome throws net::ERR_NAME_NOT_RESOLVED. The URL is fine — you can curl it from inside the container. But Chrome can't reach it.

The problem isn't your code. Chrome resolves DNS differently from the rest of your system, and in containers that difference breaks things. I've hit this in six different ways over the years, and each one needs a different fix.

Error: net::ERR_NAME_NOT_RESOLVED at https://example.com
    at GoToAction (/node_modules/puppeteer-core/lib/cjs/...)
    at Frame.goto (/node_modules/puppeteer-core/lib/cjs/...)

What this error actually means

Chrome tried to turn a hostname into an IP address and couldn't. No DNS response came back, or the response said "this domain doesn't exist." The browser never opened a TCP connection. It stopped at the DNS lookup.

Here's the confusing part. curl, wget, and nslookup might all work from the same container. Why? Chrome has its own DNS resolver. Since Chromium 83 it uses an async built-in DNS client that can bypass the system's /etc/resolv.conf. So the OS resolves a domain fine, but Chrome doesn't see it the same way.

Docker's default DNS can't reach external domains

This is the most common cause. You containerize your script, run it, every external URL fails. Internal container names resolve fine. What's going on?

Docker's bridge network uses 127.0.0.11 as its embedded DNS server. That server handles container-to-container lookups, then forwards external queries to whatever the Docker daemon found in the host's /etc/resolv.conf when it started. Straightforward enough.

And here's where it breaks. If the host uses systemd-resolved, that file points to 127.0.0.53, a stub listener that only works on the host itself. Docker tries to forward DNS there from inside the container, hits a loopback address that doesn't exist in the container's network namespace, and gets nothing back.

The fix is telling Docker which DNS servers to use:

# docker-compose.yml
services:
  screenshot:
    build: .
    dns:
      - 8.8.8.8
      - 8.8.4.4

Or on the command line:

docker run --dns 8.8.8.8 --dns 8.8.4.4 my-puppeteer-image

If Google DNS is blocked on your network (corporate firewalls, some cloud providers), ask your ops team for the internal DNS server addresses. Check cat /etc/resolv.conf on the host — the real file, not the systemd-resolved stub.

Alpine's musl resolver doesn't work the same as glibc

Alpine-based Docker images are popular because they're tiny. But they ship with musl libc instead of glibc, and musl handles DNS differently in ways that specifically bite Puppeteer.

The big one is how musl reads the ndots directive in /etc/resolv.conf. In Kubernetes, resolv.conf typically has ndots:5. With glibc, this works as expected. With musl, it tries appending search domains before querying the bare hostname. Sometimes those appended queries come back as NXDOMAIN, and musl treats that as a final answer instead of falling through to the real domain. Your external URLs fail intermittently or consistently, depending on the cluster's DNS setup.

You have two options. Switch to a Debian-based image:

# Instead of: FROM node:20-alpine
FROM node:20-slim

Or stay on Alpine and override the resolver:

FROM node:20-alpine
# Force musl to query bare hostname first
RUN echo "options ndots:0" >> /etc/resolv.conf

I generally go with node:20-slim. It's about 50 MB larger, but you skip a whole category of musl-related problems (not just DNS, also font rendering and missing shared libraries). That trade-off has saved me more debugging time than the extra image size has ever cost.

AWS Lambda in a VPC can't resolve public DNS

This one catches people who add a VPC to their Lambda for RDS access and then wonder why Puppeteer suddenly can't screenshot external URLs.

Lambda functions in a VPC don't get public DNS resolution automatically. You need two VPC settings enabled:

# Check your VPC settings
aws ec2 describe-vpc-attribute --vpc-id vpc-xxxxx --attribute enableDnsSupport
aws ec2 describe-vpc-attribute --vpc-id vpc-xxxxx --attribute enableDnsHostnames

Both need to return true. But that alone isn't enough. The Lambda also needs a route to the internet, and this is where people get tripped up: Lambda doesn't get a public IP even in public subnets, unlike EC2. You need a private subnet with a NAT Gateway. Or if the Lambda doesn't actually need VPC resources, just take it out of the VPC entirely.

One more detail. The DNS server in a VPC sits at the base CIDR + 2. So if your VPC is 10.0.0.0/16, DNS is at 10.0.0.2. If you've configured a custom DHCP option set pointing to a different DNS server, make sure it can resolve public domains too.

The URL is wrong (and you won't see why)

Before you start debugging DNS infrastructure, check the URL itself. I've lost an hour to each of these, more than once.

Missing protocol: page.goto('example.com') fails because Puppeteer needs https://example.com. Without the protocol, Chrome treats it as a search query. Same error message, completely different cause.

Invisible characters. If the URL comes from a database or user input, it might contain a zero-width space (U+200B), a BOM marker (U+FEFF), or an encoded whitespace character. A regular console.log won't show them. JSON.stringify(url) will.

Undefined environment variables. process.env.TARGET_URL is undefined, so you navigate to the string "undefined." Or the variable has a trailing newline from how it was set in the Dockerfile.

// Validate the URL before navigating
const url = process.env.TARGET_URL?.trim();
if (!url) throw new Error('TARGET_URL is not set');
if (!url.startsWith('http')) throw new Error(`URL missing protocol: ${url}`);

try {
  new URL(url); // throws on malformed URLs
} catch {
  throw new Error(`Malformed URL: ${url}`);
}

await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });

CI/CD environments with restricted DNS

GitHub Actions runs on Azure-hosted VMs, and DNS generally works. But are you running Puppeteer inside a Docker container inside the runner? That's Docker-in-Docker, and the inner container might not inherit the runner's DNS settings. Same --dns fix as Docker above.

GitLab CI with the Docker executor has a similar issue. The runner creates containers with Docker's default DNS, which may not work if your GitLab instance is self-hosted behind corporate DNS. Add dns to the runner's config:

# /etc/gitlab-runner/config.toml
[[runners]]
  [runners.docker]
    dns = ["8.8.8.8", "8.8.4.4"]

CircleCI's remote Docker is the most frustrating because you can't control DNS on the remote Docker host at all. The workaround is --host-resolver-rules in Chrome's launch args, which maps specific hostnames directly to IPs and bypasses DNS entirely for those targets.

Corporate proxy and split-horizon DNS

On a corporate network, internal.company.com resolves through an internal DNS server. Deploy the same Puppeteer script to a cloud server and that hostname simply doesn't exist. The reverse happens too: external DNS resolves fine, but Chrome on a developer's machine goes through a proxy that intercepts DNS queries.

If Chrome needs to go through a proxy:

const browser = await puppeteer.launch({
  args: [
    '--proxy-server=http://proxy.company.com:8080',
    // For SOCKS proxy:
    // '--proxy-server=socks5://proxy.company.com:1080',
  ],
});

For split-horizon setups where some domains are internal and others are external, you can pin specific domains to specific IPs:

const browser = await puppeteer.launch({
  args: [
    '--host-resolver-rules=MAP internal.company.com 10.0.1.50',
  ],
});

Chrome skips DNS for that domain and connects directly to the IP. Useful for debugging. Not great for production, though. If the IP changes, your code breaks without any error message to tell you why.

Playwright has the same error, plus a bug

Everything above applies to Playwright too. Same Chromium, same DNS stack. But Playwright has an additional gotcha: issue #6771 documents a case where calling addInitScript() before navigation breaks DNS resolution in Docker. The script injection interferes with page lifecycle events, and navigation fails with ERR_NAME_NOT_RESOLVED even though DNS is configured correctly.

The fix is to inject scripts after navigation via page.evaluate() instead of before it via addInitScript(). Annoying? Yes. But if your script works without addInitScript() and breaks with it, there's your answer.

How to figure out which cause you have

Four checks, two minutes. Run them in order.

# 1. Can the OS resolve the domain?
nslookup example.com
# Fails → system DNS is broken (Docker, Lambda, or CI issue)

# 2. Can you reach it over HTTP?
curl -I https://example.com
# nslookup works but curl fails → proxy or firewall

# 3. What DNS servers is the system using?
cat /etc/resolv.conf
# 127.0.0.53 → systemd-resolved stub, Docker can't forward to it
# 127.0.0.11 → Docker embedded DNS, check if forwarding works

# 4. Is Chrome ignoring system DNS?
chromium --enable-logging --v=1 2>&1 | grep -i dns
# Look for DnsTransaction entries

If system DNS works but Chrome still can't resolve, try disabling Chrome's built-in DNS client:

const browser = await puppeteer.launch({
  args: ['--disable-async-dns'],
});

This forces Chrome to use the system's getaddrinfo() instead of its own resolver. Slower, yes (it's synchronous), but it follows whatever /etc/resolv.conf says. Good for isolating whether the problem is Chrome-specific or system-wide.

Retrying flaky DNS (not broken, just unreliable)

What if DNS works most of the time but fails once in a while? Packet loss, overloaded resolver, a cold start where the network interface isn't ready yet. Lambda is especially prone to that last one — Chrome launches and fires DNS queries before the network is fully up.

async function gotoWithRetry(page, url, options = {}, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      return await page.goto(url, {
        waitUntil: 'domcontentloaded',
        timeout: 30000,
        ...options,
      });
    } catch (err) {
      if (err.message.includes('ERR_NAME_NOT_RESOLVED') && i < retries - 1) {
        await new Promise(r => setTimeout(r, 2000 * (i + 1)));
        continue;
      }
      throw err;
    }
  }
}

First retry after 2 seconds, second after 4. If DNS is genuinely broken, all three fail fast and you know within 10 seconds. If it was transient, the second or third attempt usually goes through. In our production setup, roughly 3% of DNS failures clear up on retry. The other 97% need an actual config fix. Retry logic is a bandaid, not a solution.

Or skip DNS debugging entirely

Every cause above comes down to the same thing: you run the browser, so you own its infrastructure problems. DNS, Docker networking, VPC routing, proxy settings, resolver libraries. All yours to debug.

A screenshot API removes the whole category. Send a URL, get an image. The API handles DNS, browser management, network config — on infrastructure that's already configured correctly.

curl "https://api.screenshotrun.com/v1/screenshots/capture?\
url=https://example.com\
&format=png\
&response_type=json" \
  -H "Authorization: Bearer YOUR_API_KEY"

If you need Puppeteer for DOM manipulation, scraping, or custom automation — fix the DNS with the six causes above. But if you just need screenshots, the API has fewer moving parts. And if Chrome connects but the port is wrong, that's a different error — see ERR_CONNECTION_REFUSED in Docker. If DNS resolves but the page takes too long, see navigation timeout fixes.

FAQ#

Chrome has its own built-in DNS resolver (since Chromium 83) that can bypass your system's /etc/resolv.conf. So curl uses the system DNS and succeeds, while Chrome tries its async DNS client and fails. You can force Chrome to use system DNS with the --disable-async-dns launch flag.

Docker's bridge network forwards DNS queries to the host's /etc/resolv.conf. If the host uses systemd-resolved (127.0.0.53), that address doesn't work inside the container. Fix it by passing explicit DNS servers: docker run --dns 8.8.8.8 --dns 8.8.4.4 my-image or add a dns block in docker-compose.yml.

Yes, Playwright uses the same Chromium browser and DNS stack. All the same fixes apply. Playwright also has a known bug (issue #6771) where calling addInitScript() before navigation can break DNS resolution in Docker. The workaround is to use page.evaluate() after navigation instead.

Yes. Alpine uses musl libc instead of glibc, and musl handles the ndots directive in /etc/resolv.conf differently. In Kubernetes (where ndots:5 is typical), musl may append search domains and treat NXDOMAIN responses as final instead of falling through. Switching to node:20-slim (Debian-based) fixes it.

Run four checks in order: (1) nslookup example.com to test system DNS, (2) curl -I https://example.com to test HTTP connectivity, (3) cat /etc/resolv.conf to check which DNS servers are configured, (4) launch Chrome with --enable-logging --v=1 and grep for DnsTransaction to see Chrome's own DNS behavior.

Written by

Vitalii Holben

Co-founder and CEO of ScreenshotRun. Full-stack developer building screenshot API tools that help developers capture, automate, and monitor web pages at scale. Based in Europe, shipping code since 201...

Screenshot API in a few clicks

Free plan includes 200 screenshots every month. No credit card required.

Sign up → copy your key → make your first call