Running self-hosted services at home is great until you have to remember that Sonarr is on port 8989, Radarr is on 7878, Prowlarr is on 9696, and so on. Typing 192.168.50.116:8989 every time gets old fast. And good luck getting HTTPS to work, which means your browser yells at you or your password manager refuses to save credentials.

This post covers how to get from that situation to having clean URLs like https://sonarr.yourdomain.com that work on every device in your house, with a real trusted HTTPS certificate.

What You Need

  • A domain name (something like yourdomain.com)
  • Pi-hole running on your home network
  • Nginx Proxy Manager (NPM) running as a Docker container
  • A DNS provider with an API (this guide uses Google Cloud DNS, but Cloudflare works just as well)

The idea is simple: Pi-hole handles local DNS so your devices know that sonarr.yourdomain.com points to your server’s local IP. NPM sits on port 80/443 and routes incoming requests to the right service based on the hostname. Let’s Encrypt handles the certificate via DNS challenge so you get real HTTPS without exposing your server to the internet.

Step 1: Set Up Pi-hole Local DNS

Pi-hole v6 stores custom DNS entries in /etc/pihole/pihole.toml under the dns.hosts array. If you are running Pi-hole in Docker, that path is inside your mounted volume.

Open your pihole.toml and find the [dns] section. Add your entries to the hosts field:

[dns]
  hosts = [
    "192.168.50.116 sonarr.yourdomain.com",
    "192.168.50.116 radarr.yourdomain.com",
    "192.168.50.116 bazarr.yourdomain.com",
    "192.168.50.116 prowlarr.yourdomain.com",
    "192.168.50.116 pihole.yourdomain.com",
    "192.168.50.116 portainer.yourdomain.com",
    "192.168.50.116 qt.yourdomain.com",
    "192.168.50.116 npm.yourdomain.com"
  ] ### CHANGED, default = []

Replace 192.168.50.116 with your server’s actual local IP. After saving, restart Pi-hole:

docker restart pihole

Make sure your router uses Pi-hole as its DNS server so all devices on your network pick up these custom entries automatically.

A Note on Pi-hole v6

If you are upgrading from Pi-hole v5, the old custom.list file is no longer read by v6. You have to use the pihole.toml approach above. The custom.list file will silently be ignored.

Also, set listeningMode to ALL in pihole.toml so Pi-hole accepts queries from all your local devices:

listeningMode = "ALL"

If you run Pi-hole in Docker with bridge networking, you may hit an issue where the Docker UDP proxy does not properly relay DNS queries from external clients. The fix is to run Pi-hole with host networking:

docker run -d \
  --name pihole \
  --network host \
  --restart unless-stopped \
  -e TZ=America/New_York \
  -e WEBPASSWORD=yourpassword \
  -v /path/to/pihole/etc-pihole:/etc/pihole \
  -v /path/to/pihole/etc-dnsmasq.d:/etc/dnsmasq.d \
  pihole/pihole:latest

With host networking, Pi-hole binds directly to your server’s network interface and DNS works correctly from all devices.

If you switch Pi-hole to host networking, its web interface will try to use port 80, which may conflict with NPM. Change the web port in pihole.toml first:

[webserver]
  port = "8800o"

Step 2: Set Up Nginx Proxy Manager

NPM is the reverse proxy that sits in front of all your services. It accepts requests on ports 80 and 443 and routes them to the right backend based on the domain name in the request.

One gotcha: if you run Tailscale on the same machine, it binds to all interfaces including [::]:443, which blocks NPM from binding to 0.0.0.0:443. The fix is to bind NPM specifically to your LAN IP:

docker run -d \
  --name nginxproxymanager \
  --restart unless-stopped \
  -p 0.0.0.0:80:80 \
  -p 0.0.0.0:81:81 \
  -p 192.168.50.116:443:443 \
  -v /path/to/npm/data:/data \
  -v /path/to/npm/letsencrypt:/etc/letsencrypt \
  jc21/nginx-proxy-manager:latest

Replace 192.168.50.116 with your server’s LAN IP. Access the NPM admin UI at http://your-server-ip:81.

Step 3: Get a Wildcard Let’s Encrypt Certificate

Here is the part that makes everything work on every device without installing any custom certificates. Let’s Encrypt can issue a wildcard cert for *.yourdomain.com using a DNS challenge, where it asks you to prove ownership by adding a TXT record to your domain’s public DNS.

The catch with .dev domains (and several others like .app) is that browsers enforce HTTPS for them no matter what. This is called HSTS preloading. So you cannot use plain HTTP even for local services. You need a real certificate.

Set Up GCP Cloud DNS

This guide uses Google Cloud DNS, but the same approach works with Cloudflare or any DNS provider that has an API.

  1. Create a public zone for your domain in the GCP Cloud DNS console.
  2. Note the four nameservers GCP assigns (they look like ns-cloud-c1.googledomains.com).
  3. In your domain registrar, update the nameservers to the four GCP ones.

Wait a few minutes for propagation, then verify:

dig NS yourdomain.com @8.8.8.8

Create a Service Account for DNS Challenge

acme.sh needs permission to temporarily add TXT records to your domain during certificate issuance.

# Create service account
gcloud iam service-accounts create acme-dns-challenge \
  --display-name="ACME DNS Challenge" \
  --project=your-project-id

# Grant DNS Admin role
gcloud projects add-iam-policy-binding your-project-id \
  --member="serviceAccount:acme-dns-challenge@your-project-id.iam.gserviceaccount.com" \
  --role="roles/dns.admin"

# Download the key
gcloud iam service-accounts keys create ~/gcp-dns-key.json \
  --iam-account=acme-dns-challenge@your-project-id.iam.gserviceaccount.com \
  --project=your-project-id

Copy the key to your server:

scp ~/gcp-dns-key.json user@your-server:/home/user/gcp-dns-key.json

Issue the Certificate with acme.sh

Install acme.sh on your server:

curl https://get.acme.sh | sh -s email=you@example.com

Since the built-in dns_gcloud plugin requires the gcloud CLI, you can write a small Python helper that calls the GCP Cloud DNS API directly using your service account key. Save this as /home/user/gcp_dns_helper.py:

#!/usr/bin/env python3
import json, base64, time, urllib.request, urllib.parse, sys
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend

KEY_FILE = "/home/user/gcp-dns-key.json"

def get_token():
    key = json.load(open(KEY_FILE))
    header = base64.urlsafe_b64encode(json.dumps({"alg":"RS256","typ":"JWT"}).encode()).rstrip(b"=")
    now = int(time.time())
    claims = base64.urlsafe_b64encode(json.dumps({
        "iss": key["client_email"],
        "scope": "https://www.googleapis.com/auth/cloud-platform",
        "aud": "https://oauth2.googleapis.com/token",
        "exp": now+3600, "iat": now
    }).encode()).rstrip(b"=")
    msg = header + b"." + claims
    priv = serialization.load_pem_private_key(key["private_key"].encode(), password=None, backend=default_backend())
    sig = base64.urlsafe_b64encode(priv.sign(msg, padding.PKCS1v15(), hashes.SHA256())).rstrip(b"=")
    jwt = msg + b"." + sig
    data = urllib.parse.urlencode({"grant_type":"urn:ietf:params:oauth:grant-type:jwt-bearer","assertion":jwt.decode()}).encode()
    req = urllib.request.Request("https://oauth2.googleapis.com/token", data=data)
    return json.loads(urllib.request.urlopen(req).read())["access_token"]

def api(method, url, token, body=None):
    data = json.dumps(body).encode() if body else None
    req = urllib.request.Request(url, data=data, method=method,
        headers={"Authorization":f"Bearer {token}", "Content-Type":"application/json"})
    try:
        return json.loads(urllib.request.urlopen(req).read())
    except urllib.error.HTTPError as e:
        return json.loads(e.read())

def find_zone(token, project, domain):
    r = api("GET", f"https://dns.googleapis.com/dns/v1/projects/{project}/managedZones", token)
    for z in r.get("managedZones", []):
        dns = z["dnsName"].rstrip(".")
        if domain == dns or domain.endswith("."+dns):
            return z["name"]
    return None

def set_txt(action, domain, value):
    key = json.load(open(KEY_FILE))
    project = key["project_id"]
    token = get_token()
    zone = find_zone(token, project, domain)
    if not zone:
        print(f"ERROR: no zone found for {domain}", file=sys.stderr); sys.exit(1)

    base_url = f"https://dns.googleapis.com/dns/v1/projects/{project}/managedZones/{zone}"
    fqdn = domain if domain.endswith(".") else domain+"."

    try:
        existing = api("GET", f"{base_url}/rrsets/{urllib.parse.quote(fqdn)}/TXT", token)
        old_rrdatas = existing.get("rrdatas", [])
    except:
        old_rrdatas = []

    quoted = f'"{value}"'
    new_rrdatas = list(set(old_rrdatas + [quoted])) if action == "add" else [r for r in old_rrdatas if r != quoted]

    change = {"additions": [], "deletions": []}
    if old_rrdatas:
        change["deletions"].append({"name": fqdn, "type": "TXT", "ttl": 60, "rrdatas": old_rrdatas})
    if new_rrdatas:
        change["additions"].append({"name": fqdn, "type": "TXT", "ttl": 60, "rrdatas": new_rrdatas})

    result = api("POST", f"{base_url}/changes", token, change)
    print(f"Change status: {result.get('status', result)}")

if __name__ == "__main__":
    set_txt(sys.argv[1], sys.argv[2], sys.argv[3])

Then create a custom acme.sh DNS plugin at ~/.acme.sh/dnsapi/dns_gcpcloud.sh:

#!/usr/bin/env bash
dns_gcpcloud_add() {
  fulldomain=$1; txtvalue=$2
  python3 /home/user/gcp_dns_helper.py add "$fulldomain" "$txtvalue"
}
dns_gcpcloud_rm() {
  fulldomain=$1; txtvalue=$2
  python3 /home/user/gcp_dns_helper.py remove "$fulldomain" "$txtvalue"
}
chmod +x ~/.acme.sh/dnsapi/dns_gcpcloud.sh

Now issue the wildcard certificate:

~/.acme.sh/acme.sh --issue \
  --dns dns_gcpcloud \
  -d "yourdomain.com" \
  -d "*.yourdomain.com" \
  --server letsencrypt \
  --dnssleep 30

acme.sh will add a TXT record, wait 30 seconds for propagation, Let’s Encrypt verifies it, and the cert is issued. The TXT record is removed automatically after.

Certs live in ~/.acme.sh/yourdomain.com_ecc/.

Step 4: Upload the Certificate to NPM and Add Proxy Hosts

In the NPM admin UI at http://your-server-ip:81:

  1. Go to SSL Certificates and add a custom certificate using the files from ~/.acme.sh/yourdomain.com_ecc/:
    • Certificate: fullchain.cer
    • Private Key: yourdomain.com.key
    • Intermediate: ca.cer
  2. Go to Proxy Hosts and add one entry per service. For each one:
    • Domain: sonarr.yourdomain.com
    • Scheme: http
    • Forward Host: 127.0.0.1 (or your server’s LAN IP)
    • Forward Port: 8989
    • SSL tab: select your wildcard certificate, enable Force SSL

Repeat for each service with its correct port:

Domain Port
sonarr.yourdomain.com 8989
radarr.yourdomain.com 7878
bazarr.yourdomain.com 6767
prowlarr.yourdomain.com 9696
pihole.yourdomain.com 8800
portainer.yourdomain.com 9000
qt.yourdomain.com 8181
npm.yourdomain.com 81

Auto-Renewal

acme.sh installs a cron job during setup that checks for renewal daily. Certificates are renewed automatically about 30 days before expiry. You will want a deploy hook that pushes the renewed cert to NPM when renewal happens. The NPM API supports uploading custom certificates, so you can script this part and register it as an acme.sh deploy hook.

Result

Every device on your local network can now reach your services by name:

  • https://sonarr.yourdomain.com
  • https://radarr.yourdomain.com
  • https://pihole.yourdomain.com

No port numbers, no certificate warnings, no installing custom CAs on each device. Works in any browser, on any operating system, including phones and tablets.


This post was written with the help of AI (Claude by Anthropic).