HomeProjectsBlogContact

Localhost vs 127.0.0.1 in .env: The Bug That Only Appears on Windows

Same code. Same .env. Works on my Linux box, dies on the Windows server with ECONNREFUSED ::1:3306. Here's what localhost actually does under the hood, why 127.0.0.1 is not always the same thing, and the rules I now follow in every .env file.

Localhost vs 127.0.0.1 in .env: The Bug That Only Appears on Windows

Two hours. That's how long I lost to a one-word difference in an .env file.

The app ran perfectly on my Ubuntu dev machine. The exact same commit, deployed to a client's Windows Server box, threw this on boot:

Error: connect ECONNREFUSED ::1:3306
    at TCPConnectWrap.afterConnect [as oncomplete]

MySQL was running. The port was open. Credentials were right. The firewall was off. The only difference between working and broken was this:

DB_HOST=localhost      # broken on Windows
DB_HOST=127.0.0.1      # works everywhere

If you've ever stared at ::1 and wondered where those colons came from, this post is for you. Most of us treat localhost and 127.0.0.1 as synonyms. They aren't — and the gap between them shows up differently on Linux and Windows, in Docker, and even between MySQL and PostgreSQL.

TL;DR for the impatient

  • 127.0.0.1 is a literal IPv4 address. Nothing resolves it. It means one thing on every operating system.
  • localhost is a hostname. It gets resolved, and modern systems resolve it to both 127.0.0.1 and ::1 (the IPv6 loopback) — often with IPv6 first.
  • If your database listens on IPv4 only and your client tries ::1 first, you get a refused connection or a stall.
  • For MySQL on Linux, localhost doesn't even use TCP — it silently switches to a Unix socket, which changes your auth, your port, and your performance profile.
  • Put 127.0.0.1 in .env when you want predictable TCP. Use an explicit socket path when you want socket speed. Never rely on localhost to mean the same thing twice.

What actually happens when you type localhost

localhost is a reserved hostname, formalised in RFC 6761. Your application hands that string to the OS resolver, and the resolver hands back a list of addresses. On a typical modern machine that list is:

::1
127.0.0.1

Note the order. RFC 6724 address-selection rules prefer IPv6 over IPv4 when both are available, so ::1 usually comes first.

127.0.0.1, by contrast, skips this entire step. There is no lookup, no list, no ordering policy. The client opens a socket to that address and that's it.

So the real question isn't "which is faster to resolve." It's "which address does your client actually end up talking to, and is anything listening there?"

The IPv6 trap that broke my deployment

Here's the chain of events that produced my ECONNREFUSED ::1:3306:

  1. MySQL was configured with bind-address = 0.0.0.0. That binds IPv4 only. Nothing was listening on ::1.
  2. My Node app resolved localhost and got ::1 first.
  3. The driver connected to ::1:3306, got refused, and surfaced the error. The IPv4 address was right there in the list — but the driver never got to it.

Node.js made this worse in v17

This one bites a lot of people. Before Node 17, dns.lookup() defaulted to ipv4first, which reordered results to put IPv4 at the top. Node 17 changed the default to verbatim — meaning Node now returns addresses in whatever order the OS gave them. Overnight, apps that had always landed on 127.0.0.1 started landing on ::1.

If you're stuck on a codebase you can't fully change, you can force the old behaviour:

// at the very top of your entry file, before any connections
const dns = require('node:dns');
dns.setDefaultResultOrder('ipv4first');

Or at the process level:

NODE_OPTIONS=--dns-result-order=ipv4first node server.js

Node 20 also turned on autoSelectFamily by default, which races both address families and falls back quickly instead of hard-failing. That hides the symptom on newer runtimes — which is arguably worse, because the bug is still there waiting for the one environment where the fallback stalls instead of failing fast.

PostgreSQL has its own version of this

Same root cause, different error message:

FATAL: no pg_hba.conf entry for host "::1", user "app", database "app", SSL off

That's pg_hba.conf containing a rule for host all all 127.0.0.1/32 md5 but no matching ::1/128 line. Your app resolved localhost to IPv6, reached Postgres fine, and got rejected at the authentication layer. Add both:

host    all    all    127.0.0.1/32    scram-sha-256
host    all    all    ::1/128         scram-sha-256

Why Linux and Windows disagree

Behaviour Linux Windows
localhost in hosts file Usually an explicit 127.0.0.1 localhost line, often plus ::1 localhost Entries are commented out by default; the DNS Client service resolves it internally
Typical first address returned ::1 on dual-stack systems, but distro-dependent ::1 almost always, per RFC 6724 preference
Unix domain sockets for DBs Supported and often the default Not used by MySQL/Postgres builds; named pipes or shared memory instead
Refused connection behaviour Instant TCP RST — fails fast Instant refusal too, but stalls behind some security software
Effect of bind-address = 0.0.0.0 IPv4 only IPv4 only

The practical takeaway: on Linux you often get away with localhost because your distro's /etc/hosts and your DB defaults happen to line up. On Windows that safety net isn't there.

One more Linux-specific detail worth knowing: a firewall rule that DROPs packets instead of REJECTing them turns a fast failure into a connection-timeout hang. If your app takes 20 seconds to fail instead of 20 milliseconds, check for -j DROP before you blame DNS.

The one that really surprised me: localhost isn't TCP in MySQL

This is the part I wish someone had told me three years ago.

On Unix-like systems, the MySQL client library treats localhost as a special keyword, not a hostname. It doesn't resolve it at all — it connects over a Unix domain socket, typically /var/run/mysqld/mysqld.sock.

That has three consequences that will absolutely waste your afternoon:

1. Your DB_PORT is ignored. Moved MySQL to port 3307 and nothing changed? That's why. Sockets don't have ports.

DB_HOST=localhost
DB_PORT=3307     # completely ignored on Linux

2. Your GRANTs are different. MySQL treats 'app'@'localhost' and 'app'@'127.0.0.1' as two separate user accounts:

-- these are NOT the same user
CREATE USER 'app'@'localhost' IDENTIFIED BY 'secret';
CREATE USER 'app'@'127.0.0.1' IDENTIFIED BY 'secret';

Switch your .env from localhost to 127.0.0.1 to dodge the IPv6 problem, and you may suddenly get Access denied for user 'app'@'127.0.0.1'. Grant both, or grant neither and be deliberate about it.

3. Windows doesn't do this at all. There's no Unix socket, so localhost on Windows goes over TCP — which drops you right back into the IPv6 resolution problem. Same string, completely different transport, depending on the OS.

To force TCP explicitly on the CLI:

mysql -h localhost --protocol=TCP -u app -p

PostgreSQL does the opposite

Don't assume the MySQL rule generalises. In libpq, host=localhost is a real hostname and goes over TCP. To use a Unix socket you pass a directory path:

# TCP
DATABASE_URL=postgres://app:secret@localhost:5432/app
 
# Unix socket — note the path as the host
PGHOST=/var/run/postgresql

Two databases, two opposite interpretations of the same word. This is exactly why "just use localhost" is bad advice.

So which one is actually faster?

This is the question that got me digging in the first place, and the honest answer has two layers.

Name resolution is not your bottleneck. localhost resolves from the hosts file or an internal resolver in microseconds. If you're seeing multi-second delays, you're looking at a failed IPv6 connection attempt and retry, not a slow lookup.

Unix sockets genuinely are faster than TCP loopback. A Unix domain socket skips the TCP/IP stack entirely — no packet headers, no checksums, no connection state machine. For chatty workloads with lots of small queries, that overhead is real and measurable.

But the size of the gain depends on your query mix, your driver, and your hardware, so measure it on your own box instead of trusting a number from a blog post:

# TCP loopback
mysqlslap --host=127.0.0.1 --port=3306 --user=app --password \
  --concurrency=50 --iterations=10 --auto-generate-sql
 
# Unix socket
mysqlslap --socket=/var/run/mysqld/mysqld.sock --user=app --password \
  --concurrency=50 --iterations=10 --auto-generate-sql

For a quick latency comparison from your application layer:

console.time('tcp');
for (let i = 0; i < 1000; i++) await pool.query('SELECT 1');
console.timeEnd('tcp');

Run it against both configs. If the difference doesn't show up in your p99, optimise something else — your N+1 queries are costing you more than your transport ever will.

Docker and WSL break both options

Just when you've settled on 127.0.0.1, containers change the rules again.

Inside a container, 127.0.0.1 means the container itself. Not your host. Not the database running on your laptop. A Node container trying to reach a host MySQL on 127.0.0.1:3306 is talking to its own empty loopback.

What to use instead:

# talking to another compose service — use the service name
DB_HOST=mysql
 
# talking to the host machine, Docker Desktop (Mac/Windows)
DB_HOST=host.docker.internal
 
# talking to the host machine, Docker on Linux — requires the flag below
DB_HOST=host.docker.internal
# docker-compose.yml — needed on Linux, where host.docker.internal isn't automatic
services:
  api:
    extra_hosts:
      - "host.docker.internal:host-gateway"

WSL2 is its own adventure. Windows can reach services in WSL2 via localhost thanks to localhost forwarding, but going the other way — from WSL2 to a Windows-hosted service — historically needed the host's IP, not localhost. Newer Windows builds offer mirrored networking mode that makes this symmetric. If you have one dev on WSL2 and one on native Linux, expect their .env files to differ.

The rules I follow now

After the Windows Server incident, these went straight into our onboarding docs:

  1. Default to 127.0.0.1 in .env, not localhost. It's unambiguous, it's the same on every OS, and it removes an entire class of IPv6 bugs.
  2. If you want socket performance, say so explicitly. Set PGHOST=/var/run/postgresql or MYSQL_SOCKET=/var/run/mysqld/mysqld.sock. Don't get it by accident through a keyword.
  3. Bind your services to what you actually use. bind-address = 0.0.0.0 is IPv4 only. Use :: if you want both families. Know which one you picked.
  4. Grant both host forms during migration. 'app'@'localhost' and 'app'@'127.0.0.1' are different accounts in MySQL.
  5. Keep pg_hba.conf symmetric. Every 127.0.0.1/32 line needs a ::1/128 twin.
  6. Never hardcode a loopback address in a container image. Make DB_HOST an environment variable so compose, Kubernetes, and local dev can each supply the right value.
  7. Test on the target OS before you promise a deploy date. "Works on my machine" is a statement about your machine's resolver, not your code.

A 60-second debugging checklist

When a local connection fails, run these in order:

# 1. What does localhost actually resolve to here?
getent hosts localhost          # Linux
ping -4 localhost && ping -6 localhost   # Windows
 
# 2. What is actually listening, and on which family?
ss -tlnp | grep 3306            # Linux
netstat -ano | findstr :3306    # Windows
 
# 3. Does the IPv4 address work when you force it?
nc -vz 127.0.0.1 3306           # Linux
Test-NetConnection 127.0.0.1 -Port 3306   # Windows
 
# 4. Does IPv6 work?
nc -vz ::1 3306

If step 3 succeeds and step 4 fails, you've found it. Change .env to 127.0.0.1 and move on with your day.

Wrapping up

localhost looks like a constant and behaves like a variable. It resolves differently across operating systems, means "Unix socket" to MySQL and "TCP hostname" to PostgreSQL, points at the wrong machine inside a container, and quietly changed behaviour in Node 17. 127.0.0.1 does exactly one thing, everywhere, forever.

That predictability is worth more than any micro-optimisation. Be explicit in your config, bind your services deliberately, and test on the OS you're actually shipping to.

Have you been bitten by the ::1 problem, or found a stranger platform difference? Drop it in the comments — I'd rather read about your two lost hours than spend another two of my own.

Keep reading

More from the workbench

All articles →