From IP:Port Hell to a Proper Homelab: Building myowndomain.ca
I've been running a homelab for a while now. Proxmox, a full media stack, Wazuh SIEM, Grafana, Prometheus, a QNAP NAS — the whole thing. And for a long time, my "service discovery" was a browser bookmark folder full of entries like 192.168.1.5:8989 and 192.168.1.12/app/login.
That's fine until it isn't. It isn't fine when you're at a meetup, someone recommends a show, and you're fumbling through a notes app trying to remember which port Sonarr is on. It isn't fine when your fiancée asks how to get to Jellyfin and you have to send her a raw IP.
So I fixed it properly. This is what I built, what broke, and what I'd do differently.
The Goal
Two things, deliberately kept separate:
- Internal service discovery — every service accessible by a human-readable hostname with real HTTPS. No more IP:port.
- Remote access — reach the services I actually need when I'm away from home, securely, with fast revocation if a device is lost.
The trap most people fall into is conflating these two problems and building something messy. Solve them in layers. Internal DNS and reverse proxy first. Remote access second, on top of a foundation that already works.
The Stack
- Domain:
myowndomain.caregistered through Cloudflare Registrar (at-cost pricing, DNS management included) - DNS: AdGuard Home in Docker bridge mode, wildcard
*.myowndomain.ca→192.168.1.5 - Reverse proxy: Traefik v3.3 with wildcard Let's Encrypt cert via DNS-01 challenge
- Remote access: Tailscale with subnet route advertisement and ACL-based access control
- SSL: Wildcard cert for
*.myowndomain.caissued via Cloudflare DNS-01 — no open ports required
The full service map:
| Hostname | Service |
|---|---|
tv.myowndomain.ca | Sonarr |
movies.myowndomain.ca | Radarr |
jellyfin.myowndomain.ca | Jellyfin |
dl.myowndomain.ca | qBittorrent |
prowlarr.myowndomain.ca | Prowlarr |
grafana.myowndomain.ca | Grafana |
prometheus.myowndomain.ca | Prometheus |
wazuh.myowndomain.ca | Wazuh |
proxmox.myowndomain.ca | Proxmox |
portainer.myowndomain.ca | Portainer |
nas.myowndomain.ca | QNAP |
ai.myowndomain.ca | Ollama |
kuma.myowndomain.ca | Uptime Kuma |
dash.myowndomain.ca | Dashy |
traefik.myowndomain.ca | Traefik dashboard |
tools.myowndomain.ca | IT-Tools |
pdf.myowndomain.ca | Stirling PDF |
Layer 1: DNS with AdGuard Home
I was running Pi-hole on macvlan. Macvlan gives containers a real LAN IP — which sounds great until you discover that macvlan containers can't be reached from their own host. That's a kernel-level limitation, not a config error. I'd been working around it for months without knowing why.
The deeper problem: Pi-hole v6 changed its config system and by default ignores custom dnsmasq files unless you explicitly set etc_dnsmasq_d = true in pihole.toml. I had the wildcard config file in the right directory. Pi-hole was silently ignoring it.
I moved to AdGuard Home in bridge mode. Two reasons:
- Native wildcard DNS rewrites in the GUI. Pi-hole's GUI doesn't support wildcards — you have to drop a dnsmasq config file and hope the daemon picks it up. AdGuard's wildcard support is a single entry under Filters → DNS Rewrites:
*.myowndomain.ca→192.168.1.5. Done. - Bridge mode kills the macvlan wall. The Docker host itself can query AdGuard. Containers on the host can query AdGuard. No shim interface, no kernel namespace workarounds. The entire class of problem disappears.
One real gotcha before running AdGuard: Ubuntu runs systemd-resolved on port 53, which fights AdGuard for the port. Fix it before bringing the container up:
sudo sed -i 's/#DNSStubListener=yes/DNSStubListener=no/' /etc/systemd/resolved.conf
sudo systemctl restart systemd-resolved
sudo ln -sf /run/systemd/resolve/resolv.conf /etc/resolv.conf
Then bring AdGuard up, set the wildcard rewrite, point your router's DHCP DNS at the Docker host. Every device on the network gets AdGuard's DNS automatically — including ad-blocking — without touching individual devices. The one step most people skip is the router DHCP change, which is also the step that makes it actually work network-wide. Do it at the router, not per-device.
Layer 2: Traefik with Wildcard TLS
Traefik is configured entirely via a static config file (traefik.yml) and a dynamic config file (dynamic.yml). The dynamic file is watched — Traefik reloads it automatically when it changes, no restart needed. Every service in the homelab is defined in dynamic.yml as a router and backend service.
The key feature: DNS-01 ACME challenge via Cloudflare API. This means:
- No ports need to be open on your router
- Let's Encrypt verifies domain ownership by writing a TXT record to Cloudflare DNS
- You get a real wildcard cert for
*.myowndomain.ca - Every subdomain gets valid HTTPS automatically
certificatesResolvers:
cloudflare:
acme:
email: "[email protected]"
storage: "/letsencrypt/acme.json"
dnsChallenge:
provider: cloudflare
resolvers:
- "1.1.1.1:53"
For services with self-signed certs on the backend (Proxmox, Portainer, Wazuh), Traefik needs a serversTransport with insecureSkipVerify: true. Without it, Traefik refuses to proxy to backends it can't verify — which is correct behaviour, but you need to explicitly opt out for internal services with self-signed certs.
One thing that caught me: acme.json must be pre-created with 600 permissions or Traefik refuses to start:
touch ~/traefik/letsencrypt/acme.json
chmod 600 ~/traefik/letsencrypt/acme.json
And if Traefik logs are going to a file (filePath in config), docker logs traefik shows nothing. Your logs are in the file. I stared at an apparently-silent container for longer than I should have before figuring that out.
Layer 3: Remote Access with Tailscale
The architecture decision most people get wrong: don't use a subnet router when you don't need your whole LAN remote.
A subnet router advertises an entire CIDR block to your tailnet. Combined with ACLs to then lock it back down to specific ports on specific IPs, you're building a firewall against your own flat network in a system that was designed for something different. It works, but it's the wrong primitive for "I want to reach a few specific services from my phone."
The right approach for this use case: install Tailscale directly on the host running Traefik, and advertise only that host's IP as a /32 route. Your tailnet ACL passes port 443 to that host. Traefik handles everything from there — the same routing logic that works on your LAN works identically over the tunnel.
sudo tailscale up --advertise-routes=192.168.1.5/32 --advertise-tags=tag:homelab
The split DNS config in Tailscale's admin console is what makes hostname-based access work remotely:
- Global nameserver:
1.1.1.1(fallback for everything) - Restricted nameserver:
100.96.16.39(your host's tailnet IP), restricted to domainmyowndomain.ca
This tells every Tailscale device: for *.myowndomain.ca, ask AdGuard via the tailnet. AdGuard returns 192.168.1.5. Tailscale routes that through the /32 subnet advertisement. Traefik proxies to Sonarr. From a meetup on LTE, it's the same experience as sitting at your desk.
Critical: the nameserver must be the tailnet IP, not the LAN IP. The tailnet IP is always reachable as a direct peer. The LAN IP requires the subnet route to work, which creates a circular dependency during DNS resolution.
The Access Control Split
Not everything should be reachable remotely. Proxmox, Portainer, Wazuh — these are admin interfaces that have no business being reachable from a phone on a cellular network.
Traefik's ipAllowList middleware handles this cleanly:
middlewares:
lan-only:
ipAllowList:
sourceRange:
- "192.168.1.0/24"
- "127.0.0.1/32"
Apply it to sensitive routers. Leave it off routers you want remote. The result: one Traefik config that expresses exactly your intent — this is reachable from anywhere, this is LAN-only — without a parallel ACL system to maintain.
Important: this middleware requires Traefik running in host network mode (network_mode: host in compose, no ports mapping). In standard bridge mode, Docker NATs all incoming traffic through the bridge gateway, and Traefik sees every request as coming from 172.17.0.1 regardless of actual source. Host mode gives Traefik real source IPs, and the middleware works correctly.
What Actually Went Wrong
Honest accounting of where the session went sideways:
Snap Docker. My Docker installation was the snap package, not Docker CE from the official repo. This caused three separate problems: an AppArmor profile that blocked no-new-privileges from working on container entrypoints, an API version floor that Traefik's Docker provider couldn't meet, and a general pattern of the runtime fighting standard Docker security practices. The workarounds were DOCKER_API_VERSION=1.44 in the environment and commenting out no-new-privileges. Neither is acceptable long-term. Migration to Docker CE is pending.
Tailscale tag ordering. Tags must be defined in the ACL policy before you can apply them from the CLI. I tried --advertise-tags=tag:homelab before the policy existed. The fix is always: define the policy first, then apply it.
ACL port gaps. Port 53 needs to be in the Tailscale ACL alongside 443 and 80. Without it, DNS queries over the tunnel get dropped, nothing resolves, and the failure looks like a routing problem because there's nothing to log — Traefik never sees a request that DNS couldn't route to it.
Accumulated host state. The single biggest time-sink across the whole session was undocumented state on the Docker host — a Pi-hole config flag that silently dropped custom dnsmasq files, a snap-confined Docker runtime with non-obvious security implications, a macvlan setup whose original rationale had been forgotten. The fix isn't better troubleshooting instincts. It's reproducible infrastructure: compose files in version control, a documented host bootstrap, the discipline to write down why you made a decision when you make it rather than six months later when you can't remember.
What's Left
- Docker CE migration. Replace snap Docker with Docker CE from the official apt repo. Re-enable
no-new-privilegeson the Traefik container once the runtime isn't fighting it. - Config in git.
~/traefik/and~/adguard/should be version controlled. The next time something breaks, the diff between "it worked" and "it doesn't" should be agit logaway. - HSTS is live — after the first HTTPS visit to any
*.myowndomain.casubdomain, browsers remember to skip HTTP entirely. The Tailscale ACL allows port 80 for the redirect, but in practice HSTS means it's rarely exercised after the first visit.
The Architecture in One Paragraph
AdGuard Home resolves *.myowndomain.ca to the Docker host on LAN, with ad-blocking as a side effect. Traefik terminates TLS with a real wildcard cert and routes by hostname to whatever backend is appropriate — HTTP or HTTPS, same host or different host. Tailscale connects my devices to the host's tailnet IP, with a /32 subnet route so 192.168.1.5 is reachable remotely. Split DNS in Tailscale's admin console handles name resolution over the tunnel. An ipAllowList middleware in Traefik enforces the LAN-only boundary on admin interfaces without a parallel ACL system. Nothing is exposed to the public internet. If a device is lost, one click in the Tailscale admin console revokes its access.
The whole thing is one DNS resolver, one reverse proxy, one VPN mesh, and a domain I own. It's not clever. It's just the right tool for each layer, solving exactly one problem at each layer, with nothing extra.