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)
| Part | Details |
|---|---|
| Central machine | A Raspberry Pi at 10.67.67.21 |
| Container runtime | Docker, with Docker Compose |
| DNS server | AdGuard Home, running in a container named adguard-dns-service |
| Reverse proxy | Nginx Proxy Manager (NPM), running in a container named nginx-proxy-service |
| Router | Fritz!Box, with WireGuard enabled for remote access |
| Other machines | A Mac Mini at 10.67.67.20, and (for the time being) a MacBook M1 Pro intended for local LLMs with Ollama |
| Persistent data | A 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:
- AdGuard Home resolves an internal name to an IP address, using a DNS rewrite.
- 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.
| Setting | Where | Example |
|---|---|---|
| DNS rewrite | AdGuard Home | files.local → 10.67.67.21 (the Pi, running NPM) |
| Forward Hostname / IP | NPM proxy host | 10.67.67.20 (the machine running services) |
| Forward Port | NPM proxy host | 8080 |
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:
| Field | Value |
|---|---|
| Domain Names | files.local |
| Scheme | http |
| Forward Hostname / IP | 10.67.67.20 |
| Forward Port | 8080 |
| Block Common Exploits | On |
| Cache Assets | Off |
| Websockets Support | On 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.