https://api.localhost works in Chrome, but curl, a Python script, or a service inside a container fails with Could not resolve host: api.localhost or getaddrinfo ENOTFOUND api.localhost. The usual workaround — asking every developer to add lines to /etc/hosts — breaks the first time someone adds a service and forgets to tell the team. This page shows which clients resolve *.localhost on their own, how to cover the ones that do not, and how to make the same names work inside the Compose network. It belongs to the local HTTPS and reverse proxy topic.

The goal is that a hostname added to a Traefik label is usable everywhere — browser, terminal, test runner and container — with no per-machine file to edit and nothing to remember.

Diagnostic

Test resolution with each client type the team uses. The differences between them are the whole problem, so test all of them rather than just the browser:

#!/usr/bin/env bash
set -euo pipefail
name="api.localhost"
echo "getent:  $(getent ahosts "$name" | awk 'NR==1{print $1}' || echo FAIL)"
echo "python:  $(python3 -c "import socket;print(socket.gethostbyname('$name'))" 2>&1 | tail -1)"
echo "curl:    $(curl -sS -o /dev/null -w '%{remote_ip}' "http://$name/" 2>&1 || true)"
echo "in-ctr:  $(docker compose exec -T web getent hosts "$name" || echo FAIL)"

Expected bad output on a macOS laptop with an older resolver and a default Compose network:

getent:  FAIL
python:  socket.gaierror: [Errno 8] nodename nor servname provided, or not known
curl:    127.0.0.1
in-ctr:  FAIL

curl succeeds because it hard-codes .localhost handling; Python goes through the system resolver, which on this machine does not; and the container's embedded DNS has no idea the name exists. On a modern Linux desktop with systemd-resolved, getent and Python usually succeed and only the container line fails.

Resolution Results by Client Table of four clients showing whether each resolved api.localhost in the diagnostic. Resolution Results by Client Client macOS Linux resolved browser 127.0.0.1 127.0.0.1 curl 7.78+ 127.0.0.1 127.0.0.1 python, getent fails 127.0.0.1 inside container fails fails
Only the in-container result is consistently wrong; host results depend on the resolver in use.

Root cause

RFC 6761 reserves localhost and every name under it for loopback and says resolvers should answer them locally, but it does not force anyone to. Browsers implement it inside their own resolver. curl implements it since 7.78. systemd-resolved answers *.localhost with 127.0.0.1 and ::1 without asking upstream DNS, so most current Linux desktops work. macOS's mDNSResponder and many corporate DNS setups do not, so any program that uses getaddrinfo on those machines fails. Containers are a separate world: Docker's embedded DNS server at 127.0.0.11 resolves service names, network aliases and then forwards to the host's configured upstream — so api.localhost either fails or, worse, resolves to the container's own loopback where nothing is listening.

Resolution

  1. Browsers and curl: nothing to do. Confirm the curl version is at least 7.78 with curl --version; older curl on long-term-support distributions can be replaced by the Homebrew or static build.

  2. macOS and other resolvers that fail: add a scoped resolver for the localhost domain that points at a tiny local DNS server. dnsmasq answers every *.localhost query with loopback:

#!/usr/bin/env bash
set -euo pipefail
brew install dnsmasq
echo 'address=/localhost/127.0.0.1' > "$(brew --prefix)/etc/dnsmasq.d/localhost.conf"
echo 'conf-dir=/opt/homebrew/etc/dnsmasq.d,*.conf' >> "$(brew --prefix)/etc/dnsmasq.conf"
sudo brew services start dnsmasq
sudo mkdir -p /etc/resolver
echo 'nameserver 127.0.0.1' | sudo tee /etc/resolver/localhost >/dev/null

The /etc/resolver/localhost file tells macOS to send only *.localhost queries to dnsmasq; every other lookup still uses the normal DNS servers, so VPN and corporate DNS are unaffected. On Linux without systemd-resolved, the equivalent is a dnsmasq instance plus a server=/localhost/127.0.0.1 line in the main resolver.

  1. Inside containers: give the proxy network aliases for every public hostname, so Docker's embedded DNS resolves them to the proxy container:
services:
  proxy:
    image: traefik:v3.1
    networks:
      default:
        aliases:
          - app.localhost
          - api.localhost
          - mail.localhost

Requests from web to https://api.localhost now reach Traefik, which routes them by Host header exactly as it does for the browser. The certificate presented is the same mkcert leaf, so the container also needs the local CA mounted, as covered in fixing certificate errors inside containers.

  1. Generate the alias list from the routing labels so it cannot drift from the routes:
#!/usr/bin/env bash
set -euo pipefail
docker compose config --format json \
  | jq -r '.services[].labels // {} | to_entries[] | select(.key | test("routers\\..*\\.rule")) | .value' \
  | grep -oE 'Host\(`[^`]+`\)' | sed -E 's/Host\(`([^`]+)`\)/\1/' | sort -u

Paste the output into the aliases list, or have the bootstrap script write a compose.aliases.yaml override from it.

Resolution Layers for api.localhost Layered view of which component answers the name for each kind of client. Resolution Layers for api.localhost Browser resolver built-in loopback answer curl 7.78+ hard-coded localhost rule OS resolver systemd-resolved or dnsmasq Docker embedded DNS alias on the proxy
Each client type is covered by exactly one layer; nothing requires a hosts-file entry.

Expected output

getent:  127.0.0.1
python:  127.0.0.1
curl:    127.0.0.1
in-ctr:  172.20.0.5      app.localhost api.localhost mail.localhost

Every host client resolves to loopback, and inside the container the name resolves to the proxy's address on the Compose network. The in-container address will differ per machine; what matters is that it belongs to the proxy, which docker compose exec web getent hosts proxy confirms by returning the same IP.

Prevention

  1. Add the four-line diagnostic to the onboarding health-check script so a machine where resolution silently regresses — after an OS upgrade or a VPN client install — is caught with an actionable message.

  2. Fail CI when a Host rule has no matching alias. Run the extraction script above against docker compose config and compare it with the proxy's aliases list; any difference means a service is reachable from the browser but not from other containers.

  3. Keep every development hostname under .localhost. Mixing .localhost, .test and .local means three different resolution paths; .local in particular is reserved for multicast DNS and causes multi-second lookup delays on macOS.

First Lookup Latency by Domain Suffix Bar chart comparing typical first lookup time on macOS for three development domain suffixes. First Lookup Latency by Domain Suffix .localhost via dnsmasq 2 ms .test via hosts file 3 ms .local via mDNS about 5 s
Measured on one macOS laptop; .local waits for multicast DNS before giving up.

Platform caveats

macOS: /etc/resolver/* files are read by mDNSResponder but ignored by tools that bundle their own resolver, such as Go binaries built with the pure-Go resolver. Set GODEBUG=netdns=cgo for those tools, or rely on curl-style hard-coding in the tool itself.

WSL2: WSL generates /etc/resolv.conf from Windows by default. Either let Windows resolve *.localhost (Windows 11 does for browsers, not for all APIs) or set generateResolvConf = false in /etc/wsl.conf and point WSL at a dnsmasq instance running inside the distribution.

Apple Silicon (ARM64): Homebrew installs under /opt/homebrew, not /usr/local; the dnsmasq conf-dir line above uses the Apple Silicon path. On Intel Macs replace it with /usr/local/etc/dnsmasq.d.

VPN clients: some corporate VPNs replace the system resolver configuration on connect. If resolution breaks only while connected, add localhost to the VPN's split-DNS exclusions.

Rollback

#!/usr/bin/env bash
set -euo pipefail
sudo rm -f /etc/resolver/localhost
sudo brew services stop dnsmasq
git restore compose.yaml
docker compose up -d --force-recreate proxy

Removing the resolver file returns macOS to its default behaviour immediately; no reboot or cache flush is needed, although sudo dscacheutil -flushcache clears any cached negative answers.

Frequently Asked Questions

Why not just add entries to /etc/hosts?

Hosts files do not support wildcards, need root to edit, differ per machine and drift silently as services are added. They also do nothing inside containers. A resolver rule for the whole localhost domain plus proxy aliases covers every current and future service name at once.

Is .localhost safe to use for cookies across subdomains?

Browsers treat localhost specially and refuse a cookie with Domain=localhost. Set cookies on the exact host, or use a parent such as app.localhost shared by api.app.localhost and web.app.localhost, which browsers do accept as a cookie domain.

Why does the container resolve api.localhost to 127.0.0.1 in some images?

Some base images ship an /etc/hosts or nsswitch.conf configured to answer *.localhost locally, which sends the request to the container's own loopback. Check with docker compose exec web cat /etc/hosts; the proxy alias is consulted only after hosts-file entries, so remove the wildcard handling from that image.

Does this work with Podman instead of Docker?

Yes. Podman's aardvark-dns resolves network aliases the same way Docker's embedded DNS does, as long as the Compose project uses a user-defined network rather than the default podman bridge.