Using .localhost Subdomains Without /etc/hosts Edits
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.
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
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.macOS and other resolvers that fail: add a scoped resolver for the
localhostdomain that points at a tiny local DNS server. dnsmasq answers every*.localhostquery 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.
- 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.
- 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.
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
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.
Fail CI when a Host rule has no matching alias. Run the extraction script above against
docker compose configand compare it with the proxy's aliases list; any difference means a service is reachable from the browser but not from other containers.Keep every development hostname under
.localhost. Mixing.localhost,.testand.localmeans three different resolution paths;.localin particular is reserved for multicast DNS and causes multi-second lookup delays on macOS.
Platform caveats
macOS:
/etc/resolver/*files are read bymDNSResponderbut ignored by tools that bundle their own resolver, such as Go binaries built with the pure-Go resolver. SetGODEBUG=netdns=cgofor those tools, or rely on curl-style hard-coding in the tool itself.
WSL2: WSL generates
/etc/resolv.conffrom Windows by default. Either let Windows resolve*.localhost(Windows 11 does for browsers, not for all APIs) or setgenerateResolvConf = falsein/etc/wsl.confand point WSL at a dnsmasq instance running inside the distribution.
Apple Silicon (ARM64): Homebrew installs under
/opt/homebrew, not/usr/local; the dnsmasqconf-dirline 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
localhostto 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.