Streamlining Web App Development With Zeroconf

The sites which are using Shardine do not only have separate data storage - they all have their own domain names. I frequently need to validate that every site is able to work correctly with the changes I am making. At Cheddar we are also using multiple domains, which is a good security practice due to CORS and CSP. Until recently I didn’t really have a good setup for developing with multiple domains, but that has changed - and the setup I ended up with works really, really well. So, let’s dive in - it could work just as well for you too!

The problem of multiple hostnames

When you have an application (let’s assume it is a Rails application, for simplicity) and you run bin/rails s or bin/dev the app boots and binds to localhost:127.0.0.1. It means that it will respond on requests to your localhost IP only (regardless of which domain name is used), and it usually runs on port 3000 - which is a Rails default.

When you have multiple subdomains and you want to test them, the standard approach is editing your /etc/hosts and adding the following segment to it:

127.0.0.1  myapp-site1
127.0.0.1  myapp-site2
127.0.0.1  myapp-site3

You then need to flush your DNS caches and change the Puma config to bind not to localhost but to your link-local IP instead:

bind "tcp://0.0.0.0:3000"

This is so that Puma listens not only to localhost but to any request that comes in to your link-local IP.

This approach works, but it has some disadvantages. First, you need to edit your /etc/hosts and add (or remove) every app’s domains that you are working on. This is annoying and wasteful. Second, you can encounter problems with .local, .home and .example TLDs if you use them - so you may bump into your DNS lookups taking 5 seconds

Also, the /etc/hosts approach only makes these new names resolvable on the machine the app is running on. It is fine as long as you do not want to test your site on a mobile device, for example - a very valid use case! You want to open your app on your smartphone and examine the mobile-optimized layout, for example - as well as test the JS-heavy bits that may be slow on mobile. But just resolving to localhost will not allow your device to access your workstation where the app is running!

You can also use .lvh.me which gives you a DNS resolution to 127.0.0.1 for anything you throw at it. This removes the need to edit /etc/hosts but introduces another big problem. Some ISPs will actually filter out any DNS responses that you receive which resolve to 127.0.0.1 - this is called DNS rebinding protection and one of the dominant ISPs here in the Netherlands does it so heavy-handedly and badly that even if you configure your own DNS servers their responses get filtered out as well. This manifests as another severe DNS timeout when you try to access locally-bound hosts. And the only remedy for this is… adding those hosts to your /ect/hosts.

Bah.

Bending mDNS to your will

There is a little-known solution to all of these hurdles though. The reason .local domains resolve so long when using /etc/hosts is that .local is actually a special TLD reserved for Zeroconf DNS (also known as “multicast DNS”, or “mDNS”). What is this beast?

It is a very neat tech developed by Apple - and now supported industry-wide. It used to be called Rendezvous, and then got renamed to Bonjour. Actually, it is an amalgamation of multiple technologies, but that’s too long of a post and deserves its own page. It allows machines and devices on a local network to broadcast DNS entries without having a central DNS server configured - a machine can just “emit” messages roughly of that shape:

📣 To whom it may concern! There is a printer available under IP 192.168.15.5 on port 5674 and it supports the IPP printing protocol. It desires to be identified as nonbinary-fancyprinter.local! Please add this entry to your local DNS resolution chain. If someone wants to access this website, make sure they end up on that IP! Cheerio!

In fact, your computer - if it is a Mac, at least - already does this. You can examine all known mDNS services using this command:

$ dns-sd -B _services._dns-sd._udp
Browsing for _services._dns-sd._udp
DATE: ---Thu 15 May 2025---
11:48:42.336  ...STARTING...
Timestamp     A/R    Flags  if Domain               Service Type         Instance Name
11:48:42.337  Add        3  17 .                    _tcp.local.          _companion-link
11:48:42.337  Add        3  17 .                    _udp.local.          _asquic
11:48:42.337  Add        3  17 .                    _tcp.local.          _ssh
11:48:42.337  Add        3  17 .                    _tcp.local.          _sftp-ssh
11:48:42.337  Add        3  17 .                    _tcp.local.          _airplay
11:48:42.337  Add        3  17 .                    _tcp.local.          _raop
11:48:42.337  Add        2  17 .                    _tcp.local.          _apple-mobdev2
11:48:42.552  Add        3   1 .                    _tcp.local.          _ssh
11:48:42.552  Add        3   1 .                    _tcp.local.          _sftp-ssh
11:48:42.552  Add        3   1 .                    _tcp.local.          _smb
11:48:42.552  Add        3   1 .                    _tcp.local.          _airplay
11:48:42.552  Add        3   1 .                    _tcp.local.          _raop
11:48:42.552  Add        3   1 .                    _tcp.local.          _omnistate
11:48:42.552  Add        3   1 .                    _tcp.local.          _companion-link
11:48:42.552  Add        3  17 .                    _tcp.local.          _smb
11:48:42.552  Add        3  17 .                    _tcp.local.          _omnistate
11:48:42.552  Add        3  18 .                    _tcp.local.          _ssh
11:48:42.552  Add        3  18 .                    _tcp.local.          _sftp-ssh
11:48:42.552  Add        3  18 .                    _tcp.local.          _smb
11:48:42.552  Add        3  18 .                    _tcp.local.          _airplay
11:48:42.552  Add        3  18 .                    _tcp.local.          _raop
11:48:42.552  Add        3  18 .                    _tcp.local.          _omnistate
11:48:42.552  Add        3  18 .                    _tcp.local.          _companion-link
11:48:42.552  Add        2  18 .                    _tcp.local.          _http
11:48:43.919  Add        3  17 .                    _tcp.local.          _pdl-datastream
11:48:43.919  Add        3  17 .                    _tcp.local.          _printer
11:48:43.919  Add        3  17 .                    _tcp.local.          _ipp
11:48:43.919  Add        3  17 .                    _tcp.local.          _ipps
11:48:43.919  Add        3  17 .                    _tcp.local.          _ipp-tls
11:48:43.919  Add        2  17 .                    _tcp.local.          _http
11:48:52.724  Rmv        0  17 .                    _udp.local.          _asquic
11:48:54.976  Add        2  17 .                    _tcp.local.          _remotepairing
11:49:10.951  Rmv        0  17 .                    _tcp.local.          _remotepairing

It is a bit tricky to query, but here is how you can see all services advertising themselves as websites:

$ dns-sd -B _http._tcp
Browsing for _http._tcp
DATE: ---Thu 15 May 2025---
11:52:03.265  ...STARTING...
Timestamp     A/R    Flags  if Domain               Service Type         Instance Name
11:52:03.590  Add        2  17 local.               _http._tcp.          Brother HL-L6210DW series

See that printer there? This is the web UI of the printer that I use. Now, what if I told you that the same can be achieved for our Ruby app? Because if a printer is capable of telling our network it has a website - why don’t we?

Fun fact: few will remember but Safari used to have a separate menu (next to that other menu where RSS feeds used to be…) that you could use to pick a Bonjour website to visit (image courtesy of dangercove.com):

Bonjour Bookmarks

Those were great times… How about we tap into the wisdom of elders and make some use of this fancy Bonjour stuff for our web app?

Putting things in motion

The first stage of doing something is seeing what it is that you are doing. I am a simple man and often prefer GUIs to the Terminal (and I find the dns-sd binary output a bit obtuse), so if you are on a Mac you can follow along using Discovery

It shows:

Discovery

So, how do we do this Bonjouring’ from our Rails web app? Well, there is a long and fabled history to that. A long time ago a number of folks started doing amazing happenings on Ruby meetups called gitjouring which went as follows: folks would make their Git repos on their laptops discoverable. An attendee at a conference could see that there was a repository available for pushing, could set it as one of the Git origins and live-push to it, right during a talk! Folks would also use this as an impromptu Git hosting replacement, to be used in an absolutely chaotic, marvelous peer-to-peer fashion!

Though I haven’t been there, I’ve read and heard about those legendary events. What does remain to us, however, are the tools built for this by the amazing tenderlove - one of which is a beautiful, frugal mDNS advertiser gem called zeroconf.

And we are going to use it to set up the following domain names for our sites. The machine we are running on has a name - usually in the .local domain. If you called your Mac jakemac its Bonjour hostname will be jakemac.local. This hostname is going to become our “apex” domain name.

Next, we will set up a subdomain with the name of our app. Since our app is called cms, our subdomain for the app is going to be cms.jakemac.local. And since our sites hosted on this app all have names - these will become the subdomains of that. So, for the 3 sites: “jane”, “peter” and “tom” we want to have the following hostnames advertised and accessible for all devices on the local network:

All of these will expose our Rails app on port 3000. To start, let’s run a Zeroconf multicast from an IRB prompt - just to test the waters, as it is written in the README:

Mappe(main):001> require "zeroconf"
=> true
Mappe(main):002> ZeroConf.service "_http._tcp.local.", 8080, "test-hostname"

The call blocks - as it spins up a thread which listens to Ethernet packets and responds to them with DNS records, and our browser shows:

Test Hostname

There is a peculiarity, however. If you expand the entry for the printer (ok, I have that printer - but just to make a point) you will see that the name of the device exposed (what is called “Instance name”, or “Service name”) is not the same as the domain name:

Instance Vs Service

And it does matter for us. See, the service name in mDNS can’t include dots, while the hostname that gets published - can! To make our jane.cms.jakemac.local available, we will need to override the service name that the zeroconf gem sets to remove all the dots. The default @service_name in the gem is set as follows:

@service_name = "#{hostname}.#{service}"

but our hostname is going to include dots. Therefore, instead of using ZeroConf.service we have to instantiate the ZeroConf::Service object ourselves, and then overwrite this instance variable before we call start on it:

service = ZeroConf::Service.new("_http._tcp.local.", 3000, "jane.cms.jakemac") # Note that we do not include .local in the last argument!
service.instance_variable_set("@service_name", "auto-website-1._http._tcp.local.")
service.start

And, just like that, our automatic website appears:

Auto Website Appears

Note that you can set the hostname to whatever you want - it does not have to be a subdomain of your Mac’s hostname. But it gets even better! If we start Discovery on a Mac connected to the same network, the site will appear there too! Your subdomain is not only available locally, but it is also available for all your devices - which is perfect for mobile testing, for example!

Productizing it for your application

Let’s create a small Rack app which can serve itself using this technique, on multiple subdomains. First, let’s set up the skeleton:

# Gemfile
source "https://rubygems.org"

gem "rack"
gem "rackup"
gem "zeroconf"
gem "puma"
# config.ru
subdomains = %w( jane peter tom )
app_name = "cms"
machine = "workstation"
port = (ENV["PORT"] || 9292).to_i
service = "_http._tcp.local."

require "zeroconf"
advertiser_threads = subdomains.map do |subdomain|
  Thread.new do

    hostname = [subdomain, app_name, machine].join(".") # No .local here
    instance_name_wihout_dots = [subdomain, app_name, machine].join("-") + "." + service
    service = ZeroConf::Service.new(service, port, hostname)
    service.instance_variable_set("@service_name", instance_name_wihout_dots)
    service.start
  end
end


run ->(env) {
  body = "You are visiting #{env["HTTP_HOST"]}"
  [200, {}, [body]]
}

and start it. Note we have to use --host 0.0.0.0 so that the webserver listens to the outside requests, not only on loopback:

julik@thicc zeroconf-rack-app $ bundle exec rackup --host 0.0.0.0
Bundler is using a binstub that was created for a different gem (rackup).
You should run `bundle binstub rack` to work around a system/bundle conflict.
Puma starting in single mode...
* Puma version: 6.6.0 ("Return to Forever")
* Ruby version: ruby 3.2.2 (2023-03-30 revision e51014f9c0) [arm64-darwin24]
*  Min threads: 0
*  Max threads: 5
*  Environment: development
*          PID: 98971
* Listening on http://0.0.0.0:9292
Use Ctrl-C to stop

observe our new sites appear in Discovery:

Success

and visit it in Safari:

Safari

Success! But it gets even better. This same hostname is now also available to all local devices on your network. Such as your smartphone:

Iphone

which is perfect for local testing. It will also be available to all DNS resolvers in your tests, for example:

Mappe(main):005> Patron::Session.new.get("http://jane.cms.workstation.local:9292")
=> #<Patron::Response @status_line='HTTP/1.1 200 OK'>
Mappe(main):006> 

and your end-to-end browser tests.

Security considerations

Note that this is good for local networks where you more-or-less trust the participating devices. If you run this service on a public WiFi network someplace, it is possible that a fellow hacker will be browsing for devices and will desire to connect to your site1 to take a peek. I find this a reasonable tradeoff - but remember, Zeroconf is a product of simpler, kinder times.

If you really want to avoid this, you can try doing something either with having your sites authenticated, or using some kind of tunnel, or using SSL client certificates.

What to watch out for

It is a finicky solution because your network needs to pass multicast packets. Moreover, if your machine is connected to both wired- and WiFi- networks - they have to be part of the same network. The Zeroconf gem chooses the first interface that it can broadcast on by order of interfaces returned by the system. On the Mac, the first interface set via the “Service Order” configuration in your System Settings. For example, on my machine Ethernet is set as priority, and thus is the one that Zeroconf picks:

Iface Prio

But if you do not see your sites being advertised - the chance is that they do get advertised, just to the wrong network. And - mind the dots in the service instance name.

Some routers that bridge WiFi and Ethernet connections may “forget” to rebroadcast multicast packets between them - but I haven’t seen this in the wild. Zeroconf relies on specially constructed Ethernet packets to do its job, and if those get mucked about with - bad times await.

.local used to be the favorite TLD of Windows systems administrators to set as “company network”. If you are in a situation where it is configured like that, it will be sometimes set as default lookup domain in your DNS configuration and will fight with Zeroconf for the discovery - trying to make it so that the DNS queries go to your company’s DNS servers instead.

And, lastly, there is Rails itself: make sure to enable those hosts in Rails’ authorized host checker.

In summary

Using Zeroconf for development is a great, great hack in my opinion. It solves a whole bunch of problems:

And, as usual - thank you Aaron! and a Friday hug to you! And thanks to Chad, Phil, Evan and the rest of the crazy crew that brought us *jour back in the day.