Guide

How to create .test domains on Mac.

http://localhost:8000 works until you have three projects, cookies bleeding between them and an app that needs subdomains. Giving each project a name like shop.test fixes that. This guide covers the two ways to do it on macOS and why the TLD matters. At the end I show how Bothy, my Mac app for local PHP development, sets it up.

In short

For a site or two, add lines to /etc/hosts. For every *.test name at once, run dnsmasq with address=/.test/127.0.0.1 and create /etc/resolver/test containing nameserver 127.0.0.1. Use .test because it's reserved for this. .local belongs to Bonjour, and .dev is a real, HTTPS-only TLD.

Why .test, not .local or .dev

The top-level domain isn't cosmetic. Pick the wrong one and you get slow lookups or a browser that refuses to connect.

  • .test is reserved for testing. RFC 2606 (1999) set aside .test, .example, .invalid and .localhost so they'd never be real TLDs. RFC 6761 later listed them as special-use names. No one can register shop.test, so it will never collide with a real site.
  • .local is for multicast DNS. macOS sends .local lookups to Bonjour/mDNS (RFC 6762), the system that finds printers and other Macs on your network. Using it for dev sites causes slow, intermittent lookups and odd delays of several seconds.
  • .dev is a real TLD. Google owns it and it's on the browsers' HSTS preload list, so every browser forces HTTPS for any .dev name. The same applies to .app. Many older setups broke when this happened.
  • .localhost is also reserved, and browsers resolve *.localhost to your machine by themselves. Command-line tools and PHP's own HTTP requests may not, so it's less predictable than .test with a real resolver.

Laravel Valet, Laravel Herd and Bothy all default to .test for these reasons.

Method 1: /etc/hosts (one name at a time)

The hosts file maps names to addresses before DNS is consulted. It's built in and needs nothing installed.

  1. 1
    Edit the hosts file

    Add an IPv4 line and an IPv6 line for each name. Including ::1 avoids a delay when something tries IPv6 first.

    sudo nano /etc/hosts
    
    127.0.0.1  shop.test
    ::1        shop.test
    127.0.0.1  api.shop.test
    ::1        api.shop.test
  2. 2
    Flush the DNS cache
    sudo dscacheutil -flushcache
    sudo killall -HUP mDNSResponder
  3. 3
    Check it resolves
    ping -c1 shop.test

The limits of /etc/hosts

It's fine for a couple of sites, but it doesn't scale:

  • No wildcards. *.test isn't valid in a hosts file. Every site and every subdomain needs its own lines, including multisite subdomains and tenant subdomains.
  • It needs sudo each time, so tools can't add sites without asking for your password.
  • It's easy to forget. Old entries pile up, and an entry pointing a real domain at 127.0.0.1 can confuse you for hours.

A local DNS server fixes all of this.

Method 2: dnsmasq and /etc/resolver (every *.test name)

dnsmasq is a small DNS server. You tell it to answer every .test query with 127.0.0.1. Then macOS's per-domain resolver files send only .test lookups to it, and everything else uses your normal DNS. This is how Valet and Bothy do it.

  1. 1
    Install dnsmasq
    brew install dnsmasq
  2. 2
    Answer every .test name with 127.0.0.1

    Add these lines to dnsmasq's config. The leading dot matches test and everything under it, however many levels deep.

    # $(brew --prefix)/etc/dnsmasq.conf
    address=/.test/127.0.0.1
    listen-address=127.0.0.1
  3. 3
    Start dnsmasq as root

    DNS uses port 53, a privileged port, so the service runs under sudo as a system daemon.

    sudo brew services start dnsmasq
  4. 4
    Send .test lookups to it

    Any file in /etc/resolver/ named after a domain sends that domain's queries to the listed nameserver.

    sudo mkdir -p /etc/resolver
    echo "nameserver 127.0.0.1" | sudo tee /etc/resolver/test
  5. 5
    Check macOS picked it up

    scutil --dns should list a resolver for domain test. Then any name works with no further setup.

    scutil --dns | grep -A2 test
    ping -c1 anything.test
    ping -c1 deep.sub.anything.test

Serving the site

DNS only gets the browser to 127.0.0.1. Your web server still needs to know which folder shop.test maps to. In Caddy that's a site block named shop.test, and in nginx it's server_name shop.test. Set up a vhost for each site, or a wildcard vhost that maps the subdomain to a folder, which is what Valet does. For the certificate side, see local HTTPS on macOS.

Pitfalls and troubleshooting

  • dig shop.test says NXDOMAIN, but the browser works. dig and nslookup talk to DNS servers directly and ignore /etc/resolver. Test with dig @127.0.0.1 shop.test, ping, or dscacheutil -q host -a name shop.test.
  • Nothing resolves after a reboot. dnsmasq wasn't started as root, or its config has an error. Check sudo brew services list, and run dnsmasq --test to validate the config.
  • It stops working on a VPN. Some VPN clients take over DNS completely and ignore resolver files. Try disconnecting, or look for a split-DNS setting in the VPN client.
  • Port 53 is already in use. Another tool's DNS server is running. Valet, Herd and Bothy all run one, so only have one active. Find the owner with sudo lsof -i :53.
  • The browser searches instead of loading. Type the full http:// or https:// the first time. Some browsers don't recognise .test as a domain until you've visited one.
  • Other devices can't see your sites. That's expected. The resolver is on your Mac only. To show a site to a phone or a client, use a tunnel.

.test domains with Bothy

Bothy sets up Method 2 during first launch, the one time it asks for your password. dnsmasq answers *.test on 127.0.0.1:53, /etc/resolver/test points macOS at it, and Caddy serves each site over HTTPS. Adding a site is then one step, and subdomains are just names with a dot (app.client becomes app.client.test):

bothy site add app.client --php 8.3 --db
bothy site rename shop store          # now https://store.test

If a name stops resolving, the troubleshooting docs start with bothy doctor, which checks the resolver file and whether dnsmasq is running. bothy status shows the services. For a public URL, Share opens a temporary Cloudflare quick tunnel. Uninstalling removes /etc/resolver/test along with the rest of Bothy's setup.

Questions

Can I use wildcards in /etc/hosts on Mac?

No. The hosts file only matches exact names. For *.test, use dnsmasq with an /etc/resolver/test file.

Why shouldn't I use .local for development sites?

macOS resolves .local through Bonjour/mDNS, which leads to slow or flaky lookups. .test is reserved for exactly this use.

Why did my .dev sites stop working?

.dev became a real Google-owned TLD on the HSTS preload list, so browsers force HTTPS on every .dev name. Switch to .test.

Does /etc/resolver/test affect other websites?

No. It only applies to names ending in .test. All other lookups use your normal DNS.

How do I undo the dnsmasq setup?

Delete /etc/resolver/test, run sudo brew services stop dnsmasq, and optionally brew uninstall dnsmasq.

Doing it with Bothy: the dnsmasq and /etc/resolver/test setup happens once at first launch. After that, every site you add, subdomains included, resolves and loads at https://name.test. See what setup installs.

Get Bothy for $69.
One payment for every 1.x release. Version 2 will be a separate purchase.
Buy now