The suffix you choose for local hostnames decides how much setup you need. On our workstation, app.localhost reached a local server with no configuration, app.local.test failed until we told the client where it lives, and every DNS resolver we asked said that neither name exists.

The suffixes and what each one does

Suffix How names resolve Secure context over plain HTTP Good for
.localhost Loopback, answered by the operating system or client; RFC 6761 tells resolver libraries to do exactly that Yes One machine running several services
.test Only through your hosts file or a DNS server you run; reserved by RFC 6761 and never delegated publicly No Names that mirror production, or that other devices must resolve
.local Multicast DNS on the local network (RFC 6762) No Device discovery, not application hostnames
Invented suffix Whatever public DNS says; it may be a real top-level domain No Nothing
A real subdomain you own Public DNS No Services that other people or systems must reach

The secure-context column matters because browsers reserve some APIs for secure contexts. MDN lists hosts named localhost or ending in .localhost as potentially trustworthy even over http://; http://app.local.test is not, so it needs HTTPS for those APIs. MDN also notes that http: sites cannot set cookies with the Secure attribute, with an exception for localhost.

Invented suffixes fail in less obvious ways. .dev, for example, is a real top-level domain, and hstspreload.org’s status API reports any .dev name, including an invented one we tried, as preloaded through the dev entry, so preload-aware browsers will only use HTTPS for it. The HSTS preload guide explains what preloading commits a name to.

What the resolvers said

Live capture, 26 September 2026. Asking the workstation’s default resolver, Cloudflare (1.1.1.1), and Google (8.8.8.8) for both names:

$ dig +nocmd app.local.test A +noall +comments +authority | grep -E 'status|SOA'
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 30118
test.			10800	IN	SOA	localhost. nobody.invalid. 1 3600 1200 604800 10800
$ dig +nocmd app.localhost A +noall +comments +authority | grep -E 'status|SOA'
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 5641
localhost.		86400	IN	SOA	localhost. root.localhost. 2004061611 86400 10800 604800 86400
$ dig +nocmd @1.1.1.1 app.local.test A +noall +comments +authority | grep -E 'status|SOA'
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 14755
.			86400	IN	SOA	a.root-servers.net. nstld.verisign-grs.com. 2026092600 1800 900 604800 86400
$ dig +nocmd @1.1.1.1 app.localhost A +noall +comments +authority | grep -E 'status|SOA'
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 32843
.			86400	IN	SOA	a.root-servers.net. nstld.verisign-grs.com. 2026092600 1800 900 604800 86400
$ dig +nocmd @8.8.8.8 app.local.test A +noall +comments +authority | grep -E 'status|SOA'
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 33314
.			86380	IN	SOA	a.root-servers.net. nstld.verisign-grs.com. 2026092600 1800 900 604800 86400
$ dig +nocmd @8.8.8.8 app.localhost A +noall +comments +authority | grep -E 'status|SOA'
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 44346
.			86137	IN	SOA	a.root-servers.net. nstld.verisign-grs.com. 2026092600 1800 900 604800 86400

Every answer is NXDOMAIN, but look at the SOA lines:

  • The default resolver answered from zones it serves itself. The SOA records for test. and localhost. do not come from the public root, which has no such zones. For .test, that is what RFC 6761 asks of caching servers: answer negatively without sending the query onward.
  • 1.1.1.1 and 8.8.8.8 returned the root zone’s SOA, the normal answer for a top-level domain that does not exist.
  • None of the three returned a loopback address for app.localhost, although RFC 6761 says caching servers should.

So public DNS will never resolve a .test name, and DNS is not what makes .localhost names work.

What the operating system and curl did

Live capture, 26 September 2026. The macOS 26.6 system resolver, queried with dscacheutil and ping:

$ dscacheutil -q host -a name app.localhost
name: localhost
ipv6_address: ::1

name: localhost
ip_address: 127.0.0.1

$ dscacheutil -q host -a name app.local.test
$ ping -c1 app.localhost
PING localhost (127.0.0.1): 56 data bytes
64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.056 ms

--- localhost ping statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 0.056/0.056/0.056/0.000 ms
$ ping -c1 app.local.test
ping: cannot resolve app.local.test: Unknown host

Live capture, 26 September 2026. curl 8.7.1 against a throwaway server started with python3 -m http.server 8741 --bind 127.0.0.1, which listens on IPv4 loopback only:

$ curl -sI --max-time 5 http://app.localhost:8741/
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.13.2
Date: Sat, 26 Sep 2026 14:54:21 GMT
Content-type: text/html
Content-Length: 3
Last-Modified: Sat, 26 Sep 2026 14:46:54 GMT

$ curl -sS -I --max-time 5 http://app.local.test:8741/
curl: (6) Could not resolve host: app.local.test
$ curl -sI --max-time 5 --resolve app.local.test:8741:127.0.0.1 http://app.local.test:8741/ | head -1
HTTP/1.0 200 OK
$ curl -sv --max-time 5 -o /dev/null http://app.localhost:8741/ 2>&1 | grep -E "resolved|IPv|Trying|refused|Connected"
* Host app.localhost:8741 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8741...
* connect to ::1 port 8741 from ::1 port 53070 failed: Connection refused
*   Trying 127.0.0.1:8741...
* Connected to app.localhost (127.0.0.1) port 8741

What to take from it:

  • macOS answered app.localhost itself, mapping it to localhost (both ::1 and 127.0.0.1) with no hosts entry and no DNS involved. app.local.test did not resolve at all.
  • curl --resolve pins a name to an address for one command, which is handy for testing a .test name without editing the hosts file.
  • curl tried ::1 first and fell back to 127.0.0.1 because our server listened on IPv4 only. Not every client retries like that, so bind development servers to both loopback addresses, or at least to the one your clients try first.
  • We tested macOS only. Run the same curl -v check on each operating system your team uses before standardizing on a suffix.

Choosing between .localhost and .test

Choose .localhost when one machine runs everything. On a system that behaves like our macOS test, names such as app.localhost and api.localhost need no hosts entries; browsers count them as secure contexts, and Caddy gives them locally trusted certificates automatically. The limit is physical: on a phone or a teammate’s laptop, app.localhost means that device’s own loopback interface, never your machine.

Choose .test when names must mirror production or reach other devices. app.local.test and api.local.test map one-to-one onto app.example.com and api.example.com, and a DNS server on your network can hand them to phones and tablets. The cost is the resolution step and a local CA for HTTPS. In exchange, RFC 6761 keeps .test out of public delegation, so these names cannot collide with anyone’s real site.

On a single machine, the .test setup starts with hosts file entries:

127.0.0.1    app.local.test
127.0.0.1    api.local.test

Skip .local for development hostnames: RFC 6762 requires that any query for a name ending in .local. go to the multicast DNS address, not to your DNS server.

HTTPS with mkcert

mkcert creates a local certificate authority, installs it in the system trust store (and in Firefox and Java trust stores where it finds them), and signs certificates with it:

mkcert -install
mkdir -p ./certs
mkcert -cert-file ./certs/local-dev.pem \
  -key-file ./certs/local-dev-key.pem \
  app.local.test api.local.test

That produces one certificate covering both names. Without -cert-file and -key-file, mkcert names the files after the first host plus a count of the extra names, like the example.com+5.pem in its README. The README also carries three cautions worth repeating:

  • rootCA-key.pem “gives complete power to intercept secure requests from your machine”, so never share it. mkcert -CAROOT prints the folder where it lives.
  • mkcert is meant for development, not production, and should not be used on end users’ machines.
  • Other devices trust these certificates only after you install rootCA.pem on them. On iOS that also means enabling the root in Settings, and on Android an app must opt in to user-installed roots.

HTTPS with Caddy

Caddy can serve mkcert’s files or run its own local CA. With mkcert’s files:

app.local.test {
	tls ./certs/local-dev.pem ./certs/local-dev-key.pem
	reverse_proxy 127.0.0.1:3000
}

api.local.test {
	tls ./certs/local-dev.pem ./certs/local-dev-key.pem
	reverse_proxy 127.0.0.1:4000
}

Caddy’s documentation lists manually loaded certificates among the things that keep its automatic HTTPS from activating, so Caddy will not manage these files; regenerate them with mkcert before they expire. To let Caddy’s internal CA issue and manage certificates instead, replace the file paths with tls internal.

For .localhost names you need neither. Caddy’s automatic HTTPS treats localhost, names under .localhost, .local, .internal, and .home.arpa, and IP addresses as ineligible for public certificates, and serves them with certificates from its own locally trusted CA. The first time, it may ask for a password to install its root in your trust store:

app.localhost {
	reverse_proxy 127.0.0.1:3000
}

.test is not on that list, so a bare app.local.test block would send Caddy to a public ACME CA, which cannot validate a name that does not exist in public DNS. Give every .test site either tls internal or certificate files.

Sharing names with other devices

Hosts files stop scaling once phones, tablets, or teammates need the names. The usual next step:

  1. Run a DNS server on your network that serves a local.test zone pointing at the development machine’s LAN address, and point test devices at it.
  2. Install the local CA’s root on each device: mkcert’s rootCA.pem, or Caddy’s root from pki/authorities/local in its data directory.
  3. Bind development servers to the LAN interface, not only to loopback.

.localhost names cannot be shared this way, because every device resolves them to itself.

When local stops being enough

Some failures mean you have outgrown local names:

Symptom Likely cause
curl: (6) Could not resolve host for a .test name No hosts entry or local DNS record for it
Connection refused on ::1 The server listens on IPv4 only while the client tries IPv6 first
A browser API is unavailable on http://app.local.test Not a secure context; use HTTPS, or a .localhost name
A Secure cookie is ignored over plain HTTP http: origins cannot set Secure cookies, localhost excepted
Trusted on the laptop, rejected on a phone The local CA root is not installed and enabled on the phone
A webhook or OAuth provider cannot reach you Local names are never reachable from outside your network

The last row is the signal to go public. Compare the options in tunnel URL vs. stable subdomain, and once a name is public, give it real DNS and certificate checks with the subdomain setup guide.