DEV Community

Cover image for Stop typing localhost ports: custom local domains with HAProxy and mkcert
Ron Northcutt
Ron Northcutt

Posted on AI-assisted

Stop typing localhost ports: custom local domains with HAProxy and mkcert

When you build local apps, typing port numbers gets old fast. Omnideck runs on localhost:46176. A Go microservice sits on another port. A scraper sits on a third. 127.0.0.1:46176 works, but https://omnideck.omni is cleaner and easier to remember.

Plus... it's just cooler. That's not a huge reason to do it, but it doesn't hurt! This guide maps custom .omni domains to local Omnideck instances, but you can use any pattern you like (.mine, .lab, .grid, and so on).

The stack:

  • HAProxy as the reverse proxy
  • Homebrew for installs and services
  • mkcert for local HTTPS

NOTE: This is part 1. The next article covers how to use one wildcard cert for a simpler setup.

Prerequisites

  • macOS or Linux with Homebrew installed
  • sudo access to edit /etc/hosts
  • Your apps already listening on localhost ports

Why do this?

  1. Readable URLs. omnideck.omni beats remembering ports for five projects.
  2. Production parity. Cookies, CORS, OAuth redirects, and HTTPS-only APIs behave differently on localhost than on real domain names. Testing on a domain catches these early.
  3. Multiple apps at once. Run work.omni and home.omni side by side with no port juggling.

Why Homebrew?

Homebrew keeps the proxy binary, config, and service in one place:

  • One package manager for installs and updates. brew install haproxy works on macOS and Linux.
  • Managed services. brew services start haproxy runs HAProxy in the background and starts it again when you log in.
  • Config files live in $(brew --prefix)/etc/ instead of scattered across the system root.

Step 1: map the domain in /etc/hosts

The hosts file maps hostnames to IP addresses. It has no concept of ports, so 127.0.0.1:46176 is invalid there. Add the mapping:

echo "127.0.0.1 omnideck.omni" | sudo tee -a /etc/hosts
Enter fullscreen mode Exit fullscreen mode

Run that once. Running it again appends a duplicate line.

Verify it resolves:

ping -c 1 omnideck.omni
Enter fullscreen mode Exit fullscreen mode

If a browser still shows the old page, flush the DNS cache or restart the browser. On macOS:

sudo dscacheutil -flushcache
sudo killall -HUP mDNSResponder
Enter fullscreen mode Exit fullscreen mode

Step 2: reverse proxy with HAProxy

HAProxy listens on port 80 and forwards matching requests to 127.0.0.1:46176.

Install it:

brew install haproxy
Enter fullscreen mode Exit fullscreen mode

Linux only: user services can't bind to port 80 by default. Raise the limit:

sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80
Enter fullscreen mode Exit fullscreen mode

That value resets on reboot. To keep it, put net.ipv4.ip_unprivileged_port_start=80 in /etc/sysctl.d/99-ports.conf and run sudo sysctl -p /etc/sysctl.d/99-ports.conf. Worth knowing that this lets any user process bind ports 80-1023, not just HAProxy.

On macOS you can skip this step. Since Mojave, unprivileged processes can bind low ports, but only on a wildcard address: bind *:80 works, while bind 127.0.0.1:80 still returns permission denied. The tradeoff is that a wildcard bind answers on every interface, which the config below deals with.

Edit the config at $(brew --prefix)/etc/haproxy.cfg:

global
    log stdout format raw local0

defaults
    log global
    mode http
    option httplog
    option forwardfor
    timeout connect 5s
    timeout client  1h
    timeout server  1h
    timeout tunnel  1h

frontend http_in
    bind *:80

    # Answer only requests from this machine
    tcp-request connection reject if !{ src 127.0.0.0/8 ::1 }

    # host_only strips any port the browser appends to the Host header
    acl is_omnideck req.hdr(host),host_only -i omnideck.omni

    use_backend omnideck_backend if is_omnideck

backend omnideck_backend
    server loopback 127.0.0.1:46176
Enter fullscreen mode Exit fullscreen mode

That tcp-request connection reject line is important! You're binding a wildcard, so the proxy is reachable from every device on whatever network you join, and the app behind it probably has no auth of its own.

The rule drops non-local connections before the handshake even starts. The port still shows up in a scan, but nothing off your machine gets an answer. Drop that line if you actually want the apps reachable from a phone or a VM on your LAN. But do it on purpose.

The long timeouts are deliberate. The usual 50s defaults are fine for page loads, but they cut off idle SSE and WebSocket connections, which is exactly what a streaming agent run looks like from the proxy's side. timeout tunnel is the one that governs an established WebSocket.

Check the config, then start it:

haproxy -c -f $(brew --prefix)/etc/haproxy.cfg
brew services start haproxy
Enter fullscreen mode Exit fullscreen mode

Test it:

curl -i http://omnideck.omni
Enter fullscreen mode Exit fullscreen mode

You should see an HTTP/1.1 200 OK line from your app, not a connection error.


Step 3: HTTPS with mkcert

HTTPS removes browser warnings and lets you test features that require a secure context.

Install mkcert and trust its local CA:

brew install mkcert
mkcert -install
Enter fullscreen mode Exit fullscreen mode

mkcert -install adds a root CA to your OS and browser trust stores once. After that you never import .crt files into Chrome or Firefox by hand. One catch on both macOS and Linux: Firefox keeps its own trust store, so install certutil first (brew install nss or apt install libnss3-tools) or Firefox will keep warning you.

NOTE: You can also just manually upload the cert into your browser.

Generate a certificate:

mkdir -p ~/.config/haproxy/certs
mkcert -cert-file ~/.config/haproxy/certs/omnideck.crt \
       -key-file ~/.config/haproxy/certs/omnideck.key \
       "omnideck.omni"

cat ~/.config/haproxy/certs/omnideck.crt \
    ~/.config/haproxy/certs/omnideck.key \
    > ~/.config/haproxy/certs/omnideck.pem
Enter fullscreen mode Exit fullscreen mode

HAProxy needs one combined .pem file, certificate first, then key.

Update the config for TLS termination:

global
    log stdout format raw local0

defaults
    log global
    mode http
    option httplog
    option forwardfor
    timeout connect 5s
    timeout client  1h
    timeout server  1h
    timeout tunnel  1h

# Port 80: send everything to HTTPS
frontend http_in
    bind *:80
    tcp-request connection reject if !{ src 127.0.0.0/8 ::1 }
    http-request redirect scheme https code 301

# Port 443: TLS termination
frontend https_in
    bind *:443 ssl crt /Users/you/.config/haproxy/certs/omnideck.pem alpn h2,http/1.1

    tcp-request connection reject if !{ src 127.0.0.0/8 ::1 }

    # So the app knows it's behind TLS and builds https:// redirects
    http-request set-header X-Forwarded-Proto https

    acl is_omnideck req.hdr(host),host_only -i omnideck.omni
    use_backend omnideck_backend if is_omnideck

backend omnideck_backend
    server loopback 127.0.0.1:46176
Enter fullscreen mode Exit fullscreen mode

Three notes on that block:

  • HAProxy does not expand ~ in config paths. Run echo ~/.config/haproxy/certs/omnideck.pem and paste the absolute path into the crt line. On macOS that starts with /Users/, on Linux with /home/.
  • alpn h2,http/1.1 enables HTTP/2. It needs a TLS library with ALPN support, which means OpenSSL 1.0.2 or newer. Homebrew's HAProxy links against OpenSSL 3, so you're covered.
  • Without X-Forwarded-Proto, an app that builds absolute URLs will hand back http:// links and break the OAuth redirects you set this up to test in the first place.
  • The reject rule now sits in both frontends. It runs at connection time, so a remote client gets dropped before the TLS handshake and never learns which certs you're holding.

Port 443 is covered by the sysctl setting from Step 2, so just reload:

brew services restart haproxy
Enter fullscreen mode Exit fullscreen mode

Visit https://omnideck.omni. No warning, green padlock.


What's next

One app, one domain, one padlock. Adding a second app means a second cert, a second ACL, and a second backend, which is fine once and tedious by the third time.

Part two fixes that: one wildcard cert for every .omni domain you'll ever make, a full config running three Omnideck instances side by side, and a script that does the whole thing in one command.


FAQ

Why can't /etc/hosts take port numbers?

It maps hostnames to IP addresses and nothing else. Port routing lives above DNS resolution, so it belongs in a proxy like HAProxy or nginx. See, silly things like this can teach us stuff.

Why .omni instead of .local or .dev?

.dev and .app are on the HSTS preload list, so browsers demand valid public certificates. .local is reserved for mDNS (Bonjour), which can add resolution delays. A made-up TLD like .omni avoids both problems. If you want zero collision risk, use .test (reserved by the IETF) or .internal (reserved by ICANN in 2024 for exactly this purpose). I use .omni because, again, it's cooler.

Why does my browser search instead of loading the site?

Made-up TLDs aren't in the browser's public suffix list, so Chrome treats omnideck.omni as a search query. Type http://omnideck.omni once, or take the "did you mean" prompt, and it stops guessing.

How does mkcert stop browser warnings?

mkcert creates a local certificate authority, registers it with your OS and browsers, and signs your certs with it. Browsers trust the CA, so every cert it issues is accepted with no per-site imports.

Why HAProxy instead of nginx or Caddy?

Any of them will do this. Caddy needs the least config and handles certs on its own. I reach for HAProxy because the ACL syntax stays readable as the rules pile up, the config validates before it loads, and it's the same thing I'd put in front of a real service. Use what you already know.

Top comments (0)