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
-
sudoaccess to edit/etc/hosts - Your apps already listening on localhost ports
Why do this?
-
Readable URLs.
omnideck.omnibeats remembering ports for five projects. -
Production parity. Cookies, CORS, OAuth redirects, and HTTPS-only APIs behave differently on
localhostthan on real domain names. Testing on a domain catches these early. -
Multiple apps at once. Run
work.omniandhome.omniside 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 haproxyworks on macOS and Linux. - Managed services.
brew services start haproxyruns 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
Run that once. Running it again appends a duplicate line.
Verify it resolves:
ping -c 1 omnideck.omni
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
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
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
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
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
Test it:
curl -i http://omnideck.omni
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
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
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
Three notes on that block:
- HAProxy does not expand
~in config paths. Runecho ~/.config/haproxy/certs/omnideck.pemand paste the absolute path into thecrtline. On macOS that starts with/Users/, on Linux with/home/. -
alpn h2,http/1.1enables 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 backhttp://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
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)