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.andlocalhost.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.localhostitself, mapping it tolocalhost(both::1and127.0.0.1) with no hosts entry and no DNS involved.app.local.testdid not resolve at all. curl --resolvepins a name to an address for one command, which is handy for testing a.testname without editing the hosts file.- curl tried
::1first and fell back to127.0.0.1because 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 -vcheck 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 -CAROOTprints 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.pemon 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:
- Run a DNS server on your network that serves a
local.testzone pointing at the development machine’s LAN address, and point test devices at it. - Install the local CA’s root on each device: mkcert’s
rootCA.pem, or Caddy’s root frompki/authorities/localin its data directory. - 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.