If you run a few local services at once, frontend, API, maybe auth, you probably use localhost with different ports. It works until cookies, CORS, or HTTPS show up and start arguing with each other.

.localhost subdomains take a lot of that pain out.

How .localhost works

Any name under *.localhost resolves to 127.0.0.1 on macOS, Linux, and Windows. You do not edit hosts files for that.

web.localhost:3000

127.0.0.1:3000

anything.you.want.localhost:3001

127.0.0.1:3001

auth.localhost:5000

127.0.0.1:5000

payments.localhost:5002

127.0.0.1:5002

Frontend App

Backend API

Auth Service

Webhook Listener

Clean multi-service dev environments

Instead of remembering which port is which service, I name them:

  • web.localhost:3000 -> frontend
  • api.localhost:3001 -> backend
  • auth.localhost:5000 -> auth or mock SSO
  • payments.localhost:5002 -> webhook listener

All of those land on 127.0.0.1. Route them through Vite, Webpack, Traefik, or keep the ports hard-coded. The URLs start looking closer to production, which makes config copy-paste less of a lie.

Ports do not separate cookies

Browsers ignore the port when they scope cookies. A cookie set on localhost:3000 also goes to localhost:3001. In production your services usually sit on separate hosts or subdomains with their own cookie space. On plain localhost, they share one jar.

That leads to cookies leaking across unrelated services, and it makes Domain, Path, and SameSite tests messy.

localhost:3000

Cookie Jar

localhost:3001

localhost:5000

localhost:5002

127.0.0.1 and localhost are different origins

They both point at your machine. Browsers still treat them as different sites. A cookie on localhost:3000 will not ride along to 127.0.0.1:3001.

Using .localhost subdomains

With .localhost names, each service gets its own host:

  • cookies stay separated by default
  • shared cookies work when you set Domain=.localhost
  • SameSite and Secure behave closer to production
  • CORS looks like a multi-host setup instead of one host with many ports

Other small wins: localStorage and sessionStorage split by subdomain, SameSite=None can work once you add HTTPS with mkcert, and the URLs look like the real deployment shape.

Reserved name, local only

RFC 6761 reserves .localhost. Resolvers map it to 127.0.0.1 or ::1 without asking the public internet.

That holds on the big three:

  • macOS built-in DNS
  • Linux with systemd-resolved and friends
  • Windows DNS

Practical effects I care about: the name never triggers an outbound DNS lookup, it cannot leak when a VPN or custom resolver is being weird, and nobody can register something.localhost as a real public domain.

TLS with mkcert

Some cookie flags need HTTPS. mkcert issues trusted certs for local names:

# Install mkcert
brew install mkcert  # macOS
# or
sudo apt install mkcert  # Ubuntu/Debian
# or
choco install mkcert  # Windows (Chocolatey)

# Install the local CA
mkcert -install

# Generate certificates for your domains
mkcert web.localhost api.localhost auth.localhost

After that you get https://web.localhost, https://api.localhost, and so on with a browser that trusts them.

Proxies, containers, and dev tools

.localhost drops into setups I already have:

  • route web.localhost to one container and api.localhost to another
  • reverse proxies that key off the Host header
  • Vite, Next.js, and similar tools that bind on a hostname
  • Cypress and friends that already treat .localhost as local

Vite

// vite.config.js
export default {
  server: {
    host: 'web.localhost',
    port: 3000,
    https: true
  }
}

Docker Compose

# docker-compose.yml
version: '3.8'
services:
  web:
    image: nginx
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
    extra_hosts:
      - "web.localhost:127.0.0.1"
      - "api.localhost:127.0.0.1"

Environment variables

// config.js
const isDev = process.env.NODE_ENV === 'development';

export const config = {
  apiUrl: isDev ? 'https://api.localhost' : 'https://api.production.com',
  authUrl: isDev ? 'https://auth.localhost' : 'https://auth.production.com',
  webUrl: isDev ? 'https://web.localhost' : 'https://app.production.com'
};

When something breaks

Stale DNS cache

If a subdomain refuses to resolve, flush local DNS:

# macOS
sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder

# Linux
sudo systemctl restart systemd-resolved

# Windows
ipconfig /flushdns

Port conflicts

# Check what's running on port 3000
lsof -i :3000

SSL certificate issues

# Reinstall mkcert CA
mkcert -install

# Regenerate certificates
mkcert -key-file key.pem -cert-file cert.pem web.localhost api.localhost

Closing

I reach for .localhost whenever local multi-service work starts looking like a tiny production topology. Named hosts, sane cookies, local TLS with mkcert, and less surprise when the same config moves closer to prod.