Beyond /etc/hosts: Using dnsmasq and systemd-resolved for Local DNS ResolutionStop hacking your hosts file - here's how to manage local DNS like a professional

Introduction

Every developer who has worked with local environments has been there: you open /etc/hosts, paste in another 127.0.0.1 myapp.local entry, and move on. It works, and it takes thirty seconds. But over time, that file grows into a graveyard of half-remembered project names, conflicting IP mappings, and entries that nobody is sure are still needed. On shared machines or team setups, it becomes a synchronization nightmare.

The /etc/hosts file was designed for a different era. It predates DNS itself - originally used on ARPANET to distribute a single flat file listing every known host. Using it for modern local development workflows is the equivalent of tracking project tasks in a shared text file: it works until it doesn't, and by the time it stops working, the mess is significant.

This article makes the case for replacing ad-hoc hosts file edits with proper local DNS tooling, specifically dnsmasq and systemd-resolved. Both tools are mature, well-documented, and already present on most Linux and macOS development machines. They offer wildcard domains, split-horizon DNS, conditional forwarding, and TTL control - features that /etc/hosts fundamentally cannot provide. By the end, you will have working configurations for both tools and a clear mental model of when to reach for each.

The Problem with /etc/hosts

The /etc/hosts file is a static, line-oriented mapping of hostnames to IP addresses. The kernel consults it before querying DNS, which is why it works so reliably for simple overrides. But that reliability is skin-deep. The file offers no support for wildcard entries, so if you want *.myproject.local to resolve to 127.0.0.1, you cannot express that in /etc/hosts - you must enumerate every subdomain individually.

This limitation becomes painful the moment you work with any microservices setup or multi-tenant application where subdomains are generated dynamically. A typical Kubernetes local development workflow with namespaced ingresses might require dozens of entries: api.myapp.local, auth.myapp.local, admin.myapp.local, and so on for every service and every project. Adding an entry is easy. Knowing which entries are still needed six months later is not.

There are also operational concerns. /etc/hosts is a system-wide file requiring root privileges to edit. On developer workstations this is a minor annoyance, but in CI/CD pipelines or containerized test environments, patching a system file is an antipattern that creates fragile, environment-specific setup scripts. Beyond that, the file has no concept of time-to-live or negative caching, no support for SRV or TXT records, and no way to route queries conditionally based on domain suffix. For anything beyond the simplest cases, it is the wrong tool.

How DNS Resolution Actually Works on Linux

Before configuring any tool, it helps to understand how name resolution flows through a modern Linux system. The process involves several layers, and knowing where each tool fits prevents configuration conflicts.

When an application calls getaddrinfo() (the standard POSIX resolver), the C library consults /etc/nsswitch.conf to determine the resolution order. A typical entry looks like hosts: files mdns4_minimal [NOTFOUND=return] dns resolve [!UNAVAIL=return], which tells the resolver to check local files first, then mDNS, then classic DNS, then systemd-resolved. The exact order varies by distribution, but the important point is that multiple resolution mechanisms compete and complement each other.

systemd-resolved operates as a system service (systemd-resolved.service) that listens on 127.0.0.53:53 and exposes both a DNS stub resolver and a D-Bus API. When it is active, the system's /etc/resolv.conf typically points to 127.0.0.53 or is a symlink to /run/systemd/resolve/stub-resolv.conf. Applications querying DNS talk to systemd-resolved, which handles caching, DNSSEC validation, and per-link DNS configuration.

dnsmasq, by contrast, is a standalone DNS forwarder and DHCP server. It binds to port 53 and serves responses from its own rules and cache before forwarding queries it cannot answer to upstream resolvers. On systems using systemd-resolved, dnsmasq typically runs on a different address (such as 127.0.0.1:53) with systemd-resolved forwarding specific domains to it, or dnsmasq replaces the stub resolver entirely.

Deep Dive: dnsmasq

dnsmasq is one of the most widely deployed lightweight DNS forwarders in existence. It powers the DNS functionality in NetworkManager, libvirt, minikube, and countless embedded routers. Its feature set is deliberately focused: fast DNS forwarding, simple local overrides, DHCP, and TFTP. It does not attempt to be a full authoritative DNS server, which makes it approachable and performant for local development use.

Installation

On Debian/Ubuntu, dnsmasq is available from the standard package repositories. On macOS, it is available via Homebrew. Note that on systems running systemd-resolved or NetworkManager with its own dnsmasq instance, you will need to coordinate carefully to avoid port conflicts on 0.0.0.0:53.

# Debian / Ubuntu
sudo apt install dnsmasq

# macOS (Homebrew)
brew install dnsmasq

Basic Configuration

dnsmasq reads its configuration from /etc/dnsmasq.conf and any files in /etc/dnsmasq.d/. The recommended approach for project-specific configuration is to drop files into /etc/dnsmasq.d/ rather than editing the main file, keeping changes isolated and reviewable.

# /etc/dnsmasq.d/local-dev.conf

# Resolve all *.test domains to loopback
address=/.test/127.0.0.1

# Resolve a specific project's wildcard subdomain
address=/.myapp.local/192.168.64.10

# Use a specific upstream resolver for internal company domains
server=/corp.example.com/10.0.0.1

# Cache size (number of entries, not bytes)
cache-size=1000

# Negative caching TTL for NXDOMAIN
neg-ttl=60

# Do not forward plain hostnames (no dots) to upstream resolvers
domain-needed

# Do not forward addresses in private IP space
bogus-priv

The address=/.test/127.0.0.1 directive is the feature that makes dnsmasq so useful for local development: it matches any hostname ending in .test and returns 127.0.0.1 without requiring you to list subdomains individually. This is the wildcard capability that /etc/hosts cannot provide.

Reload Without Restart

One operational advantage of dnsmasq is that configuration changes take effect on SIGHUP without dropping active DHCP leases or clearing the cache:

sudo kill -HUP $(cat /var/run/dnsmasq/dnsmasq.pid)
# or
sudo systemctl reload dnsmasq

Integrating dnsmasq with systemd-resolved

On Ubuntu 20.04+ and other modern distributions, systemd-resolved is the default stub resolver and /etc/resolv.conf points to 127.0.0.53. Running a second DNS server on port 53 of the same address would conflict. The standard solution is to run dnsmasq on 127.0.0.1:53 and configure systemd-resolved to forward specific domains to it.

# /etc/systemd/resolved.conf.d/forward-local.conf
[Resolve]
DNS=127.0.0.1
Domains=~test ~local

This tells systemd-resolved to forward all queries for .test and .local domains to 127.0.0.1, where dnsmasq is listening. All other queries continue through systemd-resolved as normal.

# /etc/dnsmasq.d/bind-loopback.conf

# Bind only to loopback to avoid conflict with systemd-resolved on 127.0.0.53
listen-address=127.0.0.1
bind-interfaces

After changes, restart both services:

sudo systemctl restart dnsmasq
sudo systemctl restart systemd-resolved

Deep Dive: systemd-resolved

systemd-resolved is a component of the systemd suite that provides network name resolution for local applications. Unlike dnsmasq, it is deeply integrated with systemd's networking stack and exposes its configuration through both files and the resolvectl command-line interface. On systems that already run systemd, it requires no additional packages.

Checking Status

resolvectl status
# Shows per-link DNS configuration, DNSSEC status, fallback DNS, and cache statistics

resolvectl query myapp.localcontent/in-progress/posts/ai-workflows-vs-ai-agents-vs-agentic-ai-a-developers-guide-to-building-intelligent-systems.mdx
# Tests resolution of a specific hostname and shows which DNS server answered

The resolvectl tool is indispensable for debugging. It shows exactly which upstream DNS server is being used for each network interface, whether DNSSEC validation is active, and what the current cache state is - none of which is visible when working with /etc/hosts.

Per-Link DNS Configuration

One of systemd-resolved's most powerful features is per-link DNS: the ability to use different DNS servers for different network interfaces. This models real-world network topologies where, say, your VPN interface should use the corporate DNS server but your WiFi interface should use your ISP's resolver.

# /etc/systemd/network/10-corporate-vpn.network
[Match]
Name=wg0

[Network]
DNS=10.0.0.53
Domains=~corp.example.com

The ~ prefix on the domain marks it as a "routing domain" - queries for corp.example.com will be forwarded to 10.0.0.53, but the entry does not affect resolution of other domains. Without the tilde, it would be treated as a search domain instead, appended to unqualified hostnames.

Local Overrides with resolved

systemd-resolved does not support wildcard DNS entries natively. For static hostname overrides without wildcard requirements, you can use the Domains directive combined with an upstream server that handles the zone. For true wildcard local domains, dnsmasq is still the more capable tool. However, if your needs are limited to a fixed set of hostnames, a combination of /etc/hosts for the specific entries and systemd-resolved for routing and caching can be sufficient and requires no additional software.

For more flexible overrides, systemd-resolved supports per-interface configuration via systemd-networkd and accepts entries from nss-resolve, the NSS module that integrates resolved into the getaddrinfo() call chain. You can verify the module is active:

grep resolve /etc/nsswitch.conf
# Expected output includes: resolve [!UNAVAIL=return]

Practical Patterns for Local Development

The most common use case for all of this machinery is local application development where you want *.myapp.local to reach your local Nginx or Caddy reverse proxy, which then routes to the appropriate service. Here is a complete working setup using dnsmasq on a machine running systemd-resolved.

Step 1: Install and configure dnsmasq

sudo apt install dnsmasq

# Create project-specific config
sudo tee /etc/dnsmasq.d/dev-local.conf > /dev/null <<'EOF'
# All .local.test domains point to local proxy
address=/.local.test/127.0.0.1

# Only bind to loopback - systemd-resolved owns 127.0.0.53
listen-address=127.0.0.1
bind-interfaces

# Reasonable cache
cache-size=500
neg-ttl=30
EOF

sudo systemctl enable --now dnsmasq

Step 2: Forward from systemd-resolved

sudo mkdir -p /etc/systemd/resolved.conf.d

sudo tee /etc/systemd/resolved.conf.d/local-dev.conf > /dev/null <<'EOF'
[Resolve]
DNS=127.0.0.1
Domains=~local.test
EOF

sudo systemctl restart systemd-resolved

Step 3: Configure the local reverse proxy

With DNS in place, configure Nginx or Caddy to route on Host header. Here is a minimal Caddy configuration:

# Caddyfile
*.local.test {
  tls internal

  @api host api.local.test
  handle @api {
    reverse_proxy localhost:3001
  }

  @frontend host app.local.test
  handle @frontend {
    reverse_proxy localhost:3000
  }
}

Caddy's tls internal directive issues a locally-trusted certificate via its built-in CA, giving you HTTPS without certificate warnings - something that requires manual certificate management with a plain hosts file approach.

Step 4: Verify end-to-end

# Confirm dnsmasq resolves the wildcard
dig api.local.test @127.0.0.1

# Confirm systemd-resolved forwards correctly
resolvectl query api.local.test

# Confirm the proxy reaches the service
curl -k https://api.local.test/health

Trade-offs and Pitfalls

No tool is free of cost, and replacing something as simple as a text file with a running daemon deserves honest scrutiny. The most immediate concern is operational overhead: dnsmasq is a service that can crash, be misconfigured, or fail to start after an OS update. When it fails, all local DNS for the configured domains fails with it, which is far more disruptive than a missing hosts file entry. Investing in a health check and a clear runbook for restarting the service is worthwhile.

On macOS, the situation is complicated by the OS's own DNS resolution layer, mDNSResponder. Homebrew's dnsmasq cannot bind to port 53 without elevated privileges, which means using pfctl port-forwarding rules or relying on the resolver directory trick (/etc/resolver/<domain>) to redirect specific TLDs to dnsmasq on a non-privileged port. The macOS approach is less elegant than Linux's systemd integration, but the resolver directory is well-documented and reliable for developer use.

The .local TLD deserves special mention. It is reserved by RFC 6762 for mDNS (Multicast DNS), and many tools - including Avahi on Linux and mDNSResponder on macOS - expect to resolve .local hostnames via multicast rather than unicast DNS. Overriding .local with dnsmasq can break service discovery (Bonjour, printer discovery, etc.) that relies on mDNS. The commonly recommended alternative is to use .test, .internal, or a private subdomain of a domain you control for local development work.

There is also the question of DNS rebinding protection. Some DNS resolvers reject responses from public DNS servers that return private IP addresses (RFC 1918 ranges) for public domain names, a defense against DNS rebinding attacks. If your tooling has this protection enabled, responses from dnsmasq returning 127.0.0.1 for custom TLDs may be silently dropped. The dnsmasq directive rebind-localhost-ok allows localhost responses through while keeping protection active for other private ranges.

Best Practices

The single most impactful choice you can make is standardizing on a TLD for local development across your team. Pick .test (the IANA-designated TLD safe for testing per RFC 2606) or a subdomain of a domain you own (e.g., *.dev.mycompany.com), document it in your project's README, and commit the dnsmasq.d configuration file to your dotfiles repository or team infrastructure repo. This transforms DNS configuration from tribal knowledge into version-controlled infrastructure.

Keep your dnsmasq.d configuration files granular and named after the project or environment they serve. One file per project means you can enable and disable configurations cleanly, and code review provides an audit trail. Avoid putting everything in dnsmasq.conf - the included-directory pattern exists precisely to keep configurations modular.

For teams or shared development environments, consider templating dnsmasq configurations alongside your docker-compose.yml or Vagrant/QEMU provisioning scripts. A Makefile target or a shell script that drops the right configuration file into /etc/dnsmasq.d/ and sends a reload signal makes onboarding deterministic. The goal is that a new team member running a single setup command gets working DNS without manually editing any system files.

Monitor the service. Even a simple systemd Wants= dependency from your development target to dnsmasq.service ensures that if the daemon is not running, systemd will warn you rather than letting DNS silently fail. For teams using Docker or Podman, be aware that containers have their own DNS resolvers by default and do not share the host's dnsmasq unless explicitly configured to do so via --dns flags or daemon.json settings.

Finally, document your DNSSEC and privacy posture. systemd-resolved can enforce DNSSEC validation, which is excellent for production-like staging environments but can cause spurious failures when local domains lack signed zones. Know whether DNSSEC is enabled in your environment (resolvectl status will tell you) and make a deliberate choice rather than discovering it through mysterious resolution failures.

Key Takeaways

Five things you can apply immediately after reading this article:

  1. Replace wildcard entries in /etc/hosts with a single dnsmasq directive. If you have more than five entries in your hosts file for local development domains, the ROI on dnsmasq is immediate.
  2. Use .test instead of .local for custom development TLDs. The .local TLD conflicts with mDNS. .test is reserved for exactly this purpose by RFC 2606.
  3. Run resolvectl status right now. It will tell you whether systemd-resolved is already managing your DNS, which resolvers are in use per interface, and whether there are any configuration issues to address before you add new tooling.
  4. Commit your dnsmasq.d configuration to version control. Treat DNS configuration as infrastructure code. A one-file addition to your dotfiles or team provisioning repo eliminates "works on my machine" DNS issues.
  5. Test your setup with dig and resolvectl query before relying on it. Browser DNS caching and OS-level caching mean that a browser test is not a reliable verification method. Always confirm resolution at the DNS layer first.

80/20 Insight

The vast majority of local DNS problems - wildcard resolution, split-horizon routing, and multi-project isolation - are solved by one dnsmasq directive and one systemd-resolved forwarding rule. You do not need to understand DNSSEC, per-link routing tables, or D-Bus interfaces to get immediate value. The address=/.test/127.0.0.1 line in a dnsmasq.d file and the corresponding Domains=~test in systemd-resolved will cover the needs of most developer workstations. Everything else in this article is for when those two lines are not enough.

Conclusion

/etc/hosts is not going away, and it remains the right tool for simple, permanent, system-wide hostname overrides. But as a primary mechanism for managing local development DNS, it does not scale - in the number of entries it requires, in the operational model it demands, and in the features it lacks. dnsmasq and systemd-resolved are mature, well-maintained tools that solve exactly the problem at hand. They are already present on most developer machines, they integrate cleanly with each other, and they make local DNS a versioned, repeatable configuration artifact rather than a manually maintained text file.

The investment is modest: thirty minutes to configure, fifteen to debug, and a handful of lines of configuration that can be shared across an entire team. In exchange, you get wildcard domains, conditional forwarding, clean project isolation, and the ability to point a local HTTPS reverse proxy at a real-looking domain name without touching a system file. For any developer who runs more than two local projects simultaneously, that trade is straightforwardly worth making.

References