Article View

Scroll down to read the full article.

IPv6 Phantom: Why Your Node.js App Won't Bind on `localhost` (EADDRNOTAVAIL) – The Obscure Kernel Fix

calendar_month July 31, 2026 |
Quick Summary: Debugging a Node.js EADDRNOTAVAIL on localhost? This guide cuts through the noise, exposing an obscure Linux kernel interaction with IPv6 disabled...
Alright, listen up. You've got a Node.js app, it works everywhere, but then you deploy it to some crusty VM or a specialized container, and suddenly, EADDRNOTAVAIL. On localhost. You ping localhost, it works. You netstat, nothing's listening. You're losing your mind. I've been there. This isn't your usual "port already in use" garbage. This is deeper. This is a phantom.

The Symptoms
Your Node.js application, usually a web server or a microservice attempting to bind to 127.0.0.1 or just localhost, throws this beauty:
Error: listen EADDRNOTAVAIL: address not available 127.0.0.1:3000
    at Server.setupListenHandle [as _setupListenHandle] (node:net:1882:21)
    at Server.listen (node:net:1946:10)
    at Object.<anonymous> (your-app.js:10:8)
    at Module._compile (node:internal/modules/cjs/loader:1256:14)
    at Module._extensions..js (node:internal/modules/cjs/loader:1310:10)
    at Module.load (node:internal/modules/cjs/loader:1119:32)
    at Module._load (node:internal/modules/cjs/loader:960:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:81:12)
    at node:internal/main/run_main_module:23:47
Notice it explicitly says 127.0.0.1. You're staring at it, thinking, "But that is available!" You try different ports. Same deal. You try 0.0.0.0. It works. You try binding to a specific external IP. It works. Only 127.0.0.1 or localhost fails.

A ghostly
Visual representation


Initial Checks (And Why They Fail)
You've already run these. They all tell you everything is fine, which just adds to the rage.
  • ping localhost: Replies. 127.0.0.1. Ok, great.
  • ping 127.0.0.1: Replies. Wonderful.
  • netstat -tulnp | grep 3000 (or your port): Nothing. No zombie process, no rogue listener.
  • ip a: Shows lo interface up, 127.0.0.1 assigned.
  • Checked sysctl net.ipv4.ip_local_port_range and net.ipv4.ip_unprivileged_port_start? All default.
You're stumped. The OS says the address is there, nothing's using the port, but Node.js is crying foul.

The Environment Where This Triggers
This specific nightmare manifests under a precise set of conditions. If you're on these, pay attention.

Operating System Kernel Version Range Node.js Version Additional Configuration
Debian/Ubuntu 4.x to 5.4 (e.g., Ubuntu 18.04 LTS, Debian 9/10) 14.x to 18.x (LTS releases) IPv6 completely disabled via kernel parameters (ipv6.disable=1)
CentOS/RHEL 3.x to 4.x (e.g., CentOS 7, RHEL 7) 12.x to 16.x IPv6 completely disabled via kernel parameters

Deep Dive – Tracing the Failure
When Node.js's net module goes to bind a server, especially when you specify localhost or omit the address entirely (allowing Node to implicitly resolve localhost), it does some interesting things. On systems with disabled IPv6, but where the system's hosts file still contains ::1 localhost, Node's resolver might still attempt to bind to ::1 before 127.0.0.1 or fail to properly handle the EADDRNOTAVAIL when the initial IPv6 bind fails.

We're dealing with a system where IPv6 is brutally ripped out at the kernel level. Check your /etc/default/grub or /etc/sysctl.conf for ipv6.disable=1. This isn't just a firewall rule; this is a full-on lobotomy.

The Root Cause

This is a nasty interaction between a Node.js runtime's network stack assumptions, glibc's resolver behavior, and a kernel that has explicitly had IPv6 disabled. Even though localhost primarily resolves to 127.0.0.1 on IPv4-only systems, and most getaddrinfo calls would eventually provide 127.0.0.1, Node.js's internal uv_getaddrinfo (part of libuv) can, under specific kernel circumstances, attempt to bind to ::1 first or fail to correctly fall back to IPv4 when an explicit localhost or no address is provided, and IPv6 is completely unavailable at the syscall level. The kernel simply rejects any attempt to open an IPv6 socket, leading directly to EADDRNOTAVAIL even for the concept of ::1 or localhost when the binding attempt first probes IPv6. It's a race condition of sorts, where the OS outright refuses the family before Node can intelligently pivot. This is a low-level dance, a bit like the intricacies we sometimes see in high-performance networking setups, where even microsecond mayhem can derail operations. For a general overview of Node.js capabilities versus alternatives, you might find our Deno vs. Node.js article insightful.

A tangled knot of glowing optical fibers and ethernet cables
Visual representation


The Fix: Explicitly Force IPv4
The solution is annoyingly simple, but not obvious if you don't trace the syscalls or understand the kernel's stubbornness. You need to explicitly tell Node.js (and by extension, the underlying net module) to only use IPv4 when resolving hostnames or binding.

There are two primary ways to do this, depending on your application:

1. Code-level fix (if you can modify the app):
When creating your server, pass 0.0.0.0 or 127.0.0.1 explicitly, and crucial for this issue, set the ipv6Only option to false and potentially the family option to 4.
const http = require('http');

    const server = http.createServer((req, res) => {
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end('Hello Node.js!\n');
    });

    // The critical fix: Explicitly bind to IPv4
    // Using 0.0.0.0 is often better in containers/VMs,
    // but 127.0.0.1 works if you only need loopback.
    server.listen(3000, '127.0.0.1', () => {
      console.log('Server running on http://127.0.0.1:3000/');
    });

    // Alternatively, if using net.createServer directly or for more control:
    // server.listen({
    //   port: 3000,
    //   host: '127.0.0.1', // or '0.0.0.0'
    //   family: 4, // Explicitly IPv4
    //   ipv6Only: false // Ensure it doesn't try to default to IPv6
    // }, () => {
    //   console.log('Server running on http://127.0.0.1:3000/');
    // });
    

2. Environment Variable Override (if you can't modify the app code easily):
This is the sledgehammer. It forces the underlying getaddrinfo calls to prefer IPv4 globally for Node.js.
NODE_OPTIONS="--dns-result-order=ipv4first" node your-app.js

Or, even better, set it globally in your environment or Dockerfile:
export NODE_OPTIONS="--dns-result-order=ipv4first"
    node your-app.js

This tells Node.js to always prioritize IPv4 addresses when resolving hostnames. For cases where you use localhost without specifying 127.0.0.1, this helps ensure that 127.0.0.1 is the first (and likely only) address Node.js tries to bind to.

Verification
After applying the fix, restart your application. The EADDRNOTAVAIL error should vanish, and your application should bind successfully. Use netstat -tulnp | grep 3000 again, and you should see your Node.js process happily listening on 127.0.0.1:3000.

Conclusion
This specific EADDRNOTAVAIL isn't a simple config mistake; it's a deep-seated incompatibility between a legacy kernel configuration (IPv6 disabled) and modern runtime assumptions. It highlights why understanding the full stack, from kernel to application, is crucial for SREs. Don't let these phantom errors waste your precious time. Force IPv4 and move on.

Discussion

Comments

Read Next