Friendly HomeLab Internal Domains: AdGuard Home and Nginx Proxy Manager

A setup record for the home network, written for whoever wants to understand how to build one.

Introduction

This guide describes how the home network gives each service a readable name, such as jelly.local for a media server and files.local for a file browser, instead of typing address like 10.67.67.20:8096 in the browser.

Local network only

Everything described here is for the local network only. Nothing is published to the internet, and the router does not forward any ports to these services. The only way in from outside the house is a private VPN tunnel, either WireGuard or Tailscale. Once a portable device is on an external network and connected through the tunnel, the same names work as they do at home.

Why it was built

The problem is that single computer might host multiple services differentiated by a port. Unfortunately many routers allow to assign friendly name on the machine level, a reverse proxy engine is needed to give friendly names on the service level.

The names are groundwork for future projects in the homelab. The plan includes running local large language models (LLMs) and using them in photo and video editing workflows. These projects will add more services across several machines, each with its own address and port. A consistent naming scheme and one central entry point keep that growth manageable. New services can be added by following the same pattern, without having to remember a growing list of numbers.

The Platform

This set up is very much Mac and Linux centric. I’ve been a Mac-head for many years now and do not own any Windows-based machines (except ones given to me by my employers)

PartDetails
Central machineA Raspberry Pi at 10.67.67.21
Container runtimeDocker, with Docker Compose
DNS serverAdGuard Home, running in a container named adguard-dns-service
Reverse proxyNginx Proxy Manager (NPM), running in a container named nginx-proxy-service
RouterFritz!Box, with WireGuard enabled for remote access
Other machinesA Mac Mini at 10.67.67.20, and (for the time being) a MacBook M1 Pro intended for local LLMs with Ollama
Persistent dataA USB drive mounted on the Pi at /mnt/sda1 holds the Docker data

The Raspberry Pi acts as the single gateway for the whole network. It answers DNS questions and receives all web traffic, then passes each request to the right machine. Some of the services it fronts run in Docker, some run directly on their hosts, while this setup handles both cases.

How AdGuard Home Was Set Up

AdGuard Home runs in Docker on the Raspberry Pi as the DNS resolver for the other devices on the network. It uses the official image adguard/adguardhome (version v0.107.65 at the time of writing). Its dashboard is available on host port 8080. The port choice matters: 8080 keeps the dashboard away from ports 80 and 443, which NPM needs.

A reference Docker Compose file looks like this. The exact file on the Pi is the authoritative one, and the paths may differ:

services:
  adguard:
    image: adguard/adguardhome:v0.107.65
    container_name: adguard-dns-service
    restart: unless-stopped
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      - "8080:3000/tcp"
    volumes:
      - /mnt/sda1/adguard/work:/opt/adguardhome/work
      - /mnt/sda1/adguard/conf:/opt/adguardhome/conf

Devices on the network use the Pi as their DNS server. This is set either in the Fritz!Box or on each device. The WireGuard tunnel is also configured to use the Pi for DNS, so the internal names resolve away from home too.

How Nginx Proxy Manager Was Set Up

NPM runs in Docker on the same Raspberry Pi. It listens on ports 80 and 443 for web traffic, and on port 81 for its own admin interface. A reference Compose file:

services:
  npm:
    image: jc21/nginx-proxy-manager:latest
    container_name: nginx-proxy-service
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "81:81"
    volumes:
      - /mnt/sda1/npm/data:/data
      - /mnt/sda1/npm/letsencrypt:/etc/letsencrypt

No router port forwards exist for ports 80, 443 or 81, so the proxy is reachable only from the local network and through the VPN. NPM stores its data, including the generated config for each proxy host, under /data inside the container.

Deploying via Portainer

Portainer is the Docker management dashboard for the homelab. It runs as a container on the Raspberry Pi and provides a web interface for viewing containers, reading logs, managing images and volumes, and deploying or editing Compose stacks.

Portainer connects to Docker through the Unix socket at  /var/run/docker.sock , which gives it administrative access to the Docker engine. Its persistent data should live on the mounted USB drive under  /mnt/sda1 , not the Raspberry Pi SD card, so user accounts, endpoints, stack definitions, and settings survive container upgrades or recreation. Because this is an internal-only system, Portainer is available only on the home LAN or through WireGuard/Tailscale—not from the public internet.

To have everything set up neatly in one place and avoid networking issues, I set up everything as one stack.

services:
  adguardhome:
    image: adguard/adguardhome:v0.107.65
    container_name: adguard-dns-service
    ports:
      - "53:53/tcp"
      - "53:53/udp"
      #- "3000:3000/tcp" # Initial setup / recovery access
      - "8080:8080/tcp" # Optional temporary direct UI access
    volumes:
      - /mnt/sda1/adguard/workdir:/opt/adguardhome/work
      - /mnt/sda1/adguard/confdir:/opt/adguardhome/conf
    restart: unless-stopped
    networks:
      - pi-net

  mariadb:
    image: jc21/mariadb-aria:10.11.5
    container_name: npm-database
    environment:
      MYSQL_ROOT_PASSWORD: ${NPM_DB_ROOT_PASSWORD}
      MYSQL_DATABASE: npm
      MYSQL_USER: npm
      MYSQL_PASSWORD: ${NPM_DB_PASSWORD}
    volumes:
      - /mnt/sda1/npm/mysql:/var/lib/mysql
    restart: unless-stopped
    networks:
      - pi-net

  nginx-proxy:
    image: jc21/nginx-proxy-manager:2.12.6
    container_name: nginx-proxy-service
    ports:
      - "80:80/tcp"
      - "81:81/tcp"
      - "443:443/tcp" # Enable later if you want HTTPS internally
    environment:
      DB_MYSQL_HOST: mariadb
      DB_MYSQL_PORT: 3306
      DB_MYSQL_USER: npm
      DB_MYSQL_PASSWORD: ${NPM_DB_PASSWORD}
      DB_MYSQL_NAME: npm
    volumes:
      - /mnt/sda1/npm/data:/data
      - /mnt/sda1/npm/letsencrypt:/etc/letsencrypt
    depends_on:
      - mariadb
    restart: unless-stopped
    networks:
      - pi-net

networks:
  pi-net:
    driver: bridge

The passwords are stored in .env files, and need to be maintained as environment variables in portainer before stack deployment.

How the Pieces Fit Together

Two components do two separate jobs:

  1. AdGuard Home resolves an internal name to an IP address, using a DNS rewrite.
  2. Nginx Proxy Manager receives the request on port 80 or 443 and forwards it to the correct machine and port.
Browser → AdGuard (name → the Pi's IP) → NPM on the Pi → backend IP:port

The DNS rewrite always points to the machine running NPM. The address and port of the real service are stored only in the NPM proxy host.

SettingWhereExample
DNS rewriteAdGuard Homefiles.local → 10.67.67.21 (the Pi, running NPM)
Forward Hostname / IPNPM proxy host10.67.67.20 (the machine running services)
Forward PortNPM proxy host8080

Adding a New Service

Step 1: Verify the service directly

Confirm the service responds at its IP and port from a client machine and from inside the NPM container:

curl -sv --max-time 10 http://10.67.67.20:8080
sudo docker exec nginx-proxy-service curl -sv --max-time 10 http://10.67.67.20:8080

Both commands should return the application’s page or a redirect to its login screen. If the second one fails, NPM cannot reach the backend, so check the network or firewall between the two hosts.

Step 2: Add the DNS rewrite

In AdGuard Home, open Filters → DNS rewrites → Add DNS rewrite and enter the name and the Pi’s address:

files.local → 10.67.67.21

Verify the answer from a client:

dig @10.67.67.21 files.local +short

The result must be the Pi’s address. If a Mac still resolves an old address, flush its DNS cache:

sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder

Step 3: Create the proxy host

In NPM, go to Hosts → Proxy Hosts → Add Proxy Host and fill in:

FieldValue
Domain Namesfiles.local
Schemehttp
Forward Hostname / IP10.67.67.20
Forward Port8080
Block Common ExploitsOn
Cache AssetsOff
Websockets SupportOn for apps with live updates

This is an example used for filebrowser service I use to drop personal files (like payslips) from my work to personal computer, so that I don’t have to install any unauthorized applications on my work machine, or send the files through 3rd party email or file sharing platforms.

Use http as the scheme when the backend serves plain HTTP, which is the default for most self-hosted apps. The hostname field takes only an IP or hostname, with no http:// and no port. Leave the Advanced tab empty, because NPM adds the WebSocket headers itself when Websockets Support is on.

Test the result:

curl -v --max-time 15 http://files.local

Step 4: Inspect the generated config

NPM writes one config file per proxy host. To print the file for a given domain:

sudo docker exec nginx-proxy-service sh -c 'grep -l "files.local" /data/nginx/proxy_host/*.conf | xargs cat'

These lines show what NPM will actually do:

set $forward_scheme http;
set $server         "10.67.67.20";
set $port           8080;
server_name files.local;

NPM also keeps separate access and error logs for each proxy host in /data/logs/, named proxy-host-<id>_access.log and proxy-host-<id>_error.log. The ID appears in the host’s three-dot menu in the NPM interface.

A Note on the .local Suffix

The names in this guide end in .local, which works with the AdGuard rewrite approach. The .local suffix is also reserved for multicast DNS, the mechanism Apple devices use to find each other on a network. The standard suffix for private home networks is .home.arpa. If a name ever resolves inconsistently on a particular device, switching the suffix is the first thing to try.