How I Fixed the Biggest Annoyance of My Homelab

My homelab started small, and so did the annoyance.
To open Jellyfin, I typed 192.168.0.x:8097. For Home Assistant, it was another IP and :8123. For Karakeep, Ollama and the rest, even more IP and port combinations to either remember or bookmark.
The bookmarks weren’t reliable either. My ZimaCube got its IP address from the router like any other device. Swap a cable or let it reconnect, and it mostly came back with a different IP, breaking every bookmark and config pointing to it. That’s worse for Jellyfin because typing a full combination IP address and port number with a TV remote will give you a taste of medieval torture.
So, one weekend I decided to fix it. I started with assigning dedicated IP address to my Zima devices (ZimaBoard and ZimaCube) but ended up with a full network clean up.
Now my setup is smooth with all the regular services running with a .internal domain. Now I just type jellyfin.internal in the browser. No IP, no port number. Saved me a midlife crisis.
My setup, before and after
Here’s what my network looked like before the cleanup. The router from my ISP feeds a TP-Link router, which runs the homelab network, with a OneMesh node extending the Wi-Fi.
ZimaBoard consumes less power and runs services like Jellyfin that need to be on all the time. ZimaCube is a powerful device, and with Nvidia Ada RTX on it, I use it for local AI exploration. To cut down on my electricity bill, I only turn it on when I need it.

And here’s what it looks like now. Both Zima devices have fixed IPs, the ZimaBoard handles DNS and the reverse proxy, and every service has a proper name.

The idea in a nutshell: AdGuard as DNS and Nginx Proxy Manager
The whole setup rests on two pieces working together. AdGuard Home, running as the network’s DNS server, turns a name like jellyfin.internal into an IP address.
Nginx Proxy Manager then looks at which name you asked for and forwards the request to the right port. Because AdGuard can only work on the IP address, not port numbers.
Once that’s in place, adding a new service is a two-step routine: one DNS rewrite in AdGuard, one proxy host in Nginx Proxy Manager. That’s it.
Step 1: Give the core devices fixed IPs
Everything in this setup depends on IP addresses that never change. If the DNS server’s IP changes, the entire setup breaks. So the first job was setting DHCP reservations on the router.
On my TP-Link, this lives under Advanced -> Network -> DHCP Server -> Address Reservation.
It actually shows me the connected device and gives the option to reserve the IP from there itself.

That may not always be the case for all the routers. So, you can find the MAC address on Linux with:
ip link show
That will show the MAC address of the current device. You can use a networking command like arp to scan the mac address of other devices connected to your network. The best place still is the router for this activity because it sees all the connected devices to the network anyways.
192.168.0.10, so no phone or laptop ever grabs an IP I’ve reserved.Here’s the addressing scheme I ended up with:
| Device | IP | Notes |
|---|---|---|
| Router | 192.168.0.1 | Homelab network gateway |
| ZimaBoard | 192.168.0.4 | Runs 24×7, hosts AdGuard and Nginx Proxy Manager |
| ZimaCube 2 Pro | 192.168.0.5 | Not always on, runs heavier services |
| Dynamic pool | 192.168.0.10 to 253 | Phones, laptops, everything else |
I have also assigned fixed IPs to Raspberry Pi and other SBCs in this setup. They are used for running local AI harnesses like Nanoclaw and Hermes agents. I am also setting up Frigate for the cameras. I will share my experience with those things in some later article.
Note that some devices may need to be rebooted or renew its DHCP lease to pick up the reserved IP.
Step 2: Install AdGuard Home on the always-on box
AdGuard Home becomes the DNS server for the entire homelab network, so it has to be up all the time. My ZimaBoard runs 24×7 while the ZimaCube doesn’t, so the choice was easy.
In the ZimaOS App Store, I installed the AdGuard Home (HOST) variant, not the regular one. Host networking lets AdGuard bind directly to port 53 and see the real IPs of clients. With Docker’s default bridge network, traffic gets NATed through the container and you lose both.
The install shows a tips popup with a config script. Its wget command failed on my system with a “Can’t be verbose and quiet at the same time” error, so I ran the same script with curl instead:
sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/bigbeartechworld/big-bear-scripts/master/generate-adguard-home-config/run.sh)"
Don’t skip sudo here. Without it, the script fails to create directories but still prints a success message.
I accepted the default config path, restarted the app from the ZimaOS dashboard, and opened http://192.168.0.4:3000 manually. Clicking the app icon doesn’t work for host-mode apps.
When ports are already taken
The setup wizard asks for an admin port and a DNS port, and both clashed with something. Port 80 for the web UI was taken, so I set AdGuard’s admin UI to 3786 instead.
Port 53 was more surprising. Unusual, right? Turns out I had a Pi-hole container running that I had completely forgotten about. I found it with:
sudo docker ps --format "{{.Names}}: {{.Ports}}"
Pi-hole and AdGuard do the same job, so there was no point running both. I removed Pi-hole.

Step 3: Point the network at AdGuard
AdGuard was running, but no device was using it yet. On my TP Link, I went to Advanced -> Network -> Internet, expanded Advanced Settings, and switched DNS Address to “Use the Following DNS Addresses”.
Primary DNS is AdGuard at 192.168.0.4, and secondary is 1.1.1.1. Here. 192.168.0.4 is the IP address of the ZimaBoard that has AdGuard running on it.

Here’s what this setting actually does. Devices on the network still use the router as their DNS server, and the router forwards their queries to AdGuard. That’s why AdGuard’s query log mostly shows the router as the client, not individual devices. For per-device stats, setting AdGuard’s IP in the DHCP Server page should work, so that the router hands it to devices directly.
1.1.1.1 secondary is a safety net, so the internet doesn’t go dark when the ZimaBoard is down. It comes with a trade-off, though. DNS clients don’t always wait for the primary to fail before trying the secondary. When a query goes to Cloudflare instead, the ad slips through and .internal names don’t resolve, since Cloudflare has no idea they exist. If you notice a hostname failing occasionally, this is the likely culprit.When a device ignores the new DNS
While testing, I manually set 192.168.0.4 as DNS on a Linux laptop through GNOME’s network settings. dig @192.168.0.4 google.com worked, but browser traffic never showed up in AdGuard’s log. Running resolvectl status revealed the system was still using the router, as GNOME hadn’t applied the change to the live connection.
Reconnecting to Wi-Fi fixed it. The more dependable way is doing it through nmcli:
nmcli connection modify "<connection-name>" ipv4.dns "192.168.0.4"
nmcli connection modify "<connection-name>" ipv4.ignore-auto-dns yes
nmcli connection down "<connection-name>" && nmcli connection up "<connection-name>"
Step 4: Create the .internal names with DNS rewrites
This is where the hostnames come to life. In AdGuard, go to Filters -> DNS rewrites -> Add DNS rewrite, enter a domain like jellyfin.internal, and point it to an IP address.

Here’s the thing. ZimaBoard runs multiple services. DNS rewrite only accepts IP address, not port numbers. If I have to add jellyfin.internal and homeassistant.internal in the DNS, both will be pointed to the same 192.168.0.4 IP address. And they won’t be resolved.
I mean, I could do zimaboard.internal:8097 and that would land me on Jellyfin but what’s the point? A proper jellyfin.internal is what I would want. We need the port numbers.

This is why we need a proxy manager to properly map the domain names with both IP addresses and the port numbers. But a proxy manager cannot act as DNS and hence we need both AdGuard DNS in combination with a tool like Ngnix Proxy Manager.
.internal? A good option was .local but it is reserved for mDNS (Bonjour, Avahi), so many devices resolve it outside your DNS server, which could lead to inconsistent results. .lan and .home aren’t reserved and could become real domains someday, just like .dev did when Google bought it. .home.arpa is official but clunky to type. In 2024, ICANN permanently reserved .internal for private networks. It’s short, readable, and will never clash with a real website. And it fits the entire homelab narrative.Step 5: Use the port numbers with Nginx Proxy Manager
DNS only translates a name into an IP. It knows nothing about ports. To make http://jellyfin.internal work without :8097, something has to listen on port 80, check which hostname was requested, and forward it to the right port. That’s a reverse proxy.
I’d have preferred Caddy, as that’s what I use on some of my servers. But Caddy wasn’t available as a one-click app in ZimaOS and I want to keep everything in Zima ecosystem. So I opted for Nginx Proxy Manager (NPM) as it does the same job with a web interface instead of a config file. Like AdGuard, it went on the always-on ZimaBoard.
Port 80 issue, again
NPM needs ports 80, 443 and 81 (its own admin UI). Port 80 was taken again, this time by zimaos-gateway, the process serving the ZimaOS dashboard for ZimaBoard. I confirmed it with:
sudo ss -tulpn | grep :80
The tempting fix is giving NPM a different port, but that defeats the whole purpose. You’d be back to typing port numbers.
Instead, I moved the ZimaOS dashboard to port 8888 from its Settings page. That was easy and that’s why I like ZimaOS. It makes managing homelab a lot easier.
Anyways, the NPM install dialog still complained about port 80 for a while, and a full ZimaBoard reboot cleared that stale check.
Adding proxy hosts
Once installed, NPM’s admin UI is at http://192.168.0.4:81. Log in with the default admin@example.com and changeme, and it asks you to set new credentials right away.
To add a service, go to Hosts -> Proxy Hosts -> Add Proxy Host and fill in the details.

- Domain Names: the hostname, like
jellyfin.internal - Scheme:
http - Forward Hostname / IP: the IP of the device running the service
- Forward Port: the service’s actual port, like
8097 - Websockets Support: on (Jellyfin and Home Assistant need it for live updates, and it doesn’t hurt the rest so I always enable it)
Save it, open http://jellyfin.internal in a new tab, and there it is. No port number.
Here’s how my proxy hosts look right now:

Troubleshooting afterwards
Network setup never goes 100% trouble free. I did a face a couple of issues. Here are at least two that I recall (and have recorded):
Home Assistant threw a 400 error
Everything worked except Home Assistant, which returned 400: Bad Request through homeassistant.internal. Direct access on port 8123 was fine. Home Assistant rejects proxied requests unless it explicitly trusts the proxy, as protection against spoofed headers.
The fix goes in Home Assistant’s configuration.yaml:
http:
use_x_forwarded_for: true
trusted_proxies:
- 172.17.0.3 # NPM container's IP on the Docker bridge network
I found the NPM container’s IP with sudo docker inspect nginxproxymanager | grep IPAddress, then restarted Home Assistant with sudo docker restart homeassistant.
One catch: this IP can change if the NPM container gets recreated. Trusting the whole bridge subnet (172.17.0.0/16) instead of a single IP is more durable.
Netflix stopped working on the TV
Shortly after switching DNS, Netflix on my smart TV refused to connect. AdGuard’s query log showed two blocked domains in red: logs.netflix.com and nrdp26.logs.netflix.com. They’re telemetry endpoints, but the Netflix app treats them as part of its connectivity check.
You can unblock an entry from the query log’s menu in AdGuard, or add allowlist rules under Filters -> Custom filtering rules:
@@||logs.netflix.com^
@@||nrdp26.logs.netflix.com^
Restart the app on the TV and it should work again.
Port conflicts
Port conflicts came up three times during this project, so this little drill is worth keeping handy. To see which process is using a port:
sudo ss -tulpn | grep :<port>
The process name in the output tells you who the culprit is. If it says docker-proxy, check which container it belongs to:
sudo docker ps --format "{{.Names}}: {{.Ports}}"
Then decide whether to remove the conflicting container or move the other service to a different port. Just don’t remap the port of the thing you’re trying to make port-free, like NPM.
One more thing to keep in mind: all of this works only inside your home network. The .internal names exist only in your AdGuard, so they won’t resolve when you’re outside, unless you bring a VPN into the picture.
Adding a new service later
This is where all the effort I put in this setup pays off. When I deployed Karakeep a few days later, giving it a proper name took just two steps:
- In AdGuard, add a DNS rewrite:
karakeep.internalpointing to the ZimaBoard (192.168.0.4). - In Nginx Proxy Manager, add a proxy host:
karakeep.internalforwarding to the service’s IP and port (14592in my case).
If the service does its own host validation, like Home Assistant, it may also need to trust the proxy.
Wrapping up
I did all this a few months ago and it has been running smoothly so far. HTTPS for the .internal names would be nice to have. Perhaps I will think about implementing it some weekend.
I am sure there are other, perhaps better (?) ways of doing this. For now, this setup works for me, and I no longer have to remember a single IP address or port number in my homelab. I hope it gives you a few ideas for taming your own.
I welcome your questions and suggestions. What else could I do here? What would you like to do about a similar setup?
![]()
