Local Development With an Ingress Proxy and Custom DNS on MacOS

Danny Thuering
Webmobix
Published in
7 min readMay 11, 2024
A developer desperately trying to manage port numbers.

As most developers do, I run the services I am working on the loopback interface on different ports and then open them directly in my browser.

Although we usually run the production environment in Kubernetes, doing so during development on my MacBook Pro is neither convenient nor comfortable. The extra abstraction layer of having an emulated Linux system running takes too many resources over time.

Still, I wanted a better development experience, and I have finally made some changes, which I will describe here.

Benefits

Using an Ingress proxy and custom domains can have multiple benefits.

Meaningful Domain Names Are More Intuitive

Instead of remembering and using arbitrary port numbers, developers can use descriptive and memorable domain names like “api.local.dev” or “frontend.local.dev”. This reduces cognitive load and makes it easier to remember and share URLs within your team.

Clearer Structure

Meaningful domain names help outline the structure of your application more clearly.

Consistent Environments

By using domain names and an Ingress proxy, your local environment mirrors the production setup, where services are often split across different domains and subdomains. This similarity helps catch environment-specific bugs early in the development cycle.

Configuration Parity

It allows you to use the same configuration for routing and other network behaviors both locally and in production, which can significantly reduce deployment issues and DevOps overhead.

Flexible Service Architecture

Easily manage and test microservices architecture by assigning each service a subdomain, like “orders.local.dev” and “users.local.dev”. This makes it simpler to develop, test, and understand service boundaries.

Security Best Practices

Implementing TLS (SSL) locally ensures that you can develop and test HTTPS features early, mirroring the secure protocols used in production. This helps in identifying and fixing protocol-related issues without having to deploy first.

Trust and Verification

Using self-signed certificates or locally trusted Certificate Authorities (CAs) can help simulate the authentication and authorization flows of your services more realistically.

Enhanced Security

Using an Ingress proxy allows for better isolation between services, reducing the risk of cross-service interference and increasing security by encapsulating service interaction behind defined APIs.

Advanced Traffic Management

Configure your proxy to route traffic based on URL paths, such as sending requests that start with “/api/” to your backend service and those starting with “/” to your front-end service. This can be particularly useful for single-page applications that rely on API backends.

Testing and Mocks

Easily point your application to mock or staging backends for testing external integrations without deploying your code, which is perfect for testing error handling and edge cases.

The Installation

I chose Traefik for the Ingress proxy and CoreDNS for the custom domain names. Both are easy to install through Homebrew and are small binaries with a small footprint when running.

Installing these applications using Homebrew is straightforward.

brew install traefik
brew install coredns

The Configuration

The Preparation

Homebrew services run daemons using MacOS "launchd". When you start a daemon with "brew services start," it copies a "plist" configuration file to "~/Library/LaunchAgents" that configures the daemon.

To look at these config files, we must find where Homebrew installs.
We can look at these environment variables to get the information.

~ env | grep HOMEBREW
HOMEBREW_PREFIX=/opt/homebrew
HOMEBREW_CELLAR=/opt/homebrew/Cellar
HOMEBREW_REPOSITORY=/opt/homebrew

When I installed the current version, the "plist" file for the CoreDNS was in the folder of the most recent CoreDNS version.

cat $HOMEBREW_CELLAR/coredns/1.11.3/homebrew.mxcl.coredns.plist

The part we are interested in is the configuration file location for CoreDNS.

<array>
<string>/opt/homebrew/opt/coredns/bin/coredns</string>
<string>-conf</string>
<string>/opt/homebrew/etc/coredns/Corefile</string>
</array>

Configuring CoreDNS

The subfolder is usually not created, so we ensure it exists before creating an empty file for our configuration.

mkdir -p $HOMEBREW_PREFIX/etc/coredns/
touch $HOMEBREW_PREFIX/etc/coredns/Corefile

Let's open the config with our favourite file editor and add the following configuration. I want to add one local domain for a project I am working on. I have chosen the ".home.arpa" top-level domain, defined in RFC8375, for usage on local networks.

home.arpa {
file /opt/homebrew/etc/coredns/db.home.arpa
log
errors
}

The configuration describes how CoreDNS should serve DNS requests for the "home.arpa" domain from the database file in its config folder.

The database file is a BIND zone file configuring how our local domain is resolved.

$ORIGIN home.arpa.
$TTL 1h
home.arpa. 3600 IN SOA ns.home.arpa. noc.home.arpa. (
2024051001 ; serial
2h ; refresh
15m ; retry
8h ; expire
4m ; minimum
)

IN NS ns.home.arpa.
ns IN A 127.0.0.1

* IN A 127.0.0.1
IN AAAA ::1

I will link a more detailed description of below. We define that all unterminated hostnames should have our domain "home.arpa" added. Then we have a Start of Authority record and a nameserver configuration, and then we define that any host should resolve to the localhost IP as IPv4 and IPv6.

Finally, we can start CoreDNS.

sudo brew services start coredns

I am using "sudo" here so that CoreDNS starts automatically when the system is startup.

Testing the Name Resolution

I am using the dig utility to query our local server for the domain "test.home.arpa" and receive the following answer.

~ dig @localhost test.home.arpa

; <<>> DiG 9.10.6 <<>> @localhost test.home.arpa
; (2 servers found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 42696
;; flags: qr aa rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;test.home.arpa. IN A

;; ANSWER SECTION:
test.home.arpa. 3600 IN A 127.0.0.1

;; Query time: 3 msec
;; SERVER: ::1#53(::1)
;; WHEN: Sat May 11 14:06:23 CEST 2024
;; MSG SIZE rcvd: 73

The subdomain "test.home.arpa" resolves to our local host address as expected.

Enabling the MacOS Resolver

When testing the name resolution for my custom domain, I pointed dig explicitly to use localhost as the DNS server. That is not possible when using a browser. Adding our nameserver to the MacOS network configuration is possible, but that has several drawbacks.
We have also not configured CoreDNS to forward requests to unknown domains to external servers.
I also often switch networks, and some use a landing page where I have to log in before I get access. These usually won't work when a custom nameserver entry is used, as they use private networks and their DNS server.

A better way is to use a custom resolver setting. The manpage has more about it.

The resolver configurations live in the "/etc/resolver" folder. That folder might not exist, so we must create it as the root user. I am also making an empty configuration file in my home folder for easy editing.

sudo mkdir /etc/resolver
touch ~/home.arpa

Let's open the configuration file in our favourite editor and add the following content.

nameserver 127.0.0.1

There is no domain entry so that the filename will be used as the domain. It tells the resolver to use the nameserver at localhost when encountering a domain ending in home.arpa.

I move the file to its location. To let the resolver refresh, I have to send the HUP signal to the mDNSResponder daemon, and then we can check its configuration.

sudo mv ~/home.arpa /etc/resolver/
sudo killall -HUP mDNSResponder
scutil --dns

We will get a long list, including the setting for the home.arpa domain.

resolver #8
domain : home.arpa
search domain[0] : home.arpa
nameserver[0] : 127.0.0.1
flags : Request A records, Request AAAA records
reach : 0x00030002 (Reachable,Local Address,Directly Reachable Address)

Unfortunately, the dig utility is unaware of the MacOS resolver, but we can use a native tool to test if our name resolution works.

~ dscacheutil -q host -a name test.home.arpa
name: test.home.arpa
ipv6_address: ::1

name: test.home.arpa
ip_address: 127.0.0.1

Configuring Traefik

First, I check the launch daemon config file for the location of the config file for Traefik.

cat $HOMEBREW_CELLAR/traefik/3.0.0/homebrew.mxcl.traefik.plist

The relevant section is this.

 <array>
<string>/opt/homebrew/opt/traefik/bin/traefik</string>
<string>--configfile=/opt/homebrew/etc/traefik/traefik.toml</string>
</array>

Like before, I first ensure the directory exists and then create an empty file.

mkdir -p /opt/homebrew/etc/traefik
touch /opt/homebrew/etc/traefik/traefik.toml

A basic configuration for Traefik serving HTTP looks like this.

[global]
checkNewVersion = false
sendAnonymousUsage = true

[api]
insecure = true
dashboard = true

[entryPoints]
[entryPoints.web]
address = ":80/tcp"
reusePort = true
asDefault = true

[providers]
[providers.file]
filename = "/opt/homebrew/etc/traefik/traefik-static.yaml"
watch = true

Here I define a web entry point that will listen on port 80. The traefik-static.yaml file defines all configurations for routes and services. Changes in that file will be detected and reloaded automatically. Only changes in this config file require restarting Traefik.

I can now add the empty configuration for the services and start Traefik.

/opt/homebrew/etc/traefik/traefik-static.yaml
brew services start traefik

A configuration can look like this, assuming an application runs on localhost port 3000.

http:
routers:
test-backend:
rule: "Host(`test.home.arpa`) && PathPrefix(`/`) "
service: test-backend
entryPoints: web
services:
test-backend:
loadBalancer:
servers:
- url: "http://127.0.0.1:3000/"

Here, a router is defined to forward all traffic from the web entry point with a Host header test.home.arpa to the backend listening on port 3000.

Traefik should automatically detect the change, and the application should now respond when http://test.home.arpa is opened in the browser.

Conclusion

While I still have to run all services on different ports on localhost, the actual access in the browser and the configuration of the single services is much more aligned to the production environment.

Using the single Ingress service allows setting up TLS and the routing of external calls from webhooks trough services like ngrok without having to configure separate endpoints.

I will explore these options in later articles.

This was my first technical article and I hope you could follow my thoughts easily and it was helpful. I am looking forward to get feedback from you.

I am working in a software development company that is currently build a tool to manage requirements. Go and check it out at https://vanillaround.com.

--

--

Danny Thuering
Webmobix

Co-Founder & CTO of Webmobix // Co-Founder of Billte // Participant of F10 Accelerator program