The Zero-Trust Homelab Manual: MacVLAN, Private PKI, and Tailscale
Source: Dev.to
Overview
Most homelab guides take shortcuts: they use Docker bridge networks (hiding everything behind one IP), HTTP‑only connections, or rely on public domains. I wanted a production‑grade environment at home.
- Real Networking: Containers should have physical IPs on my network (MacVLAN).
- Real Security: Zero ports open to the Internet, but valid SSL certificates everywhere (private PKI).
- Real Access: Accessible from anywhere in the world without exposing the network (Tailscale).
This is the comprehensive documentation on how to build this exact stack on Ubuntu Server.
Prerequisites
| Item | Details |
|---|---|
| Hardware | A dedicated server/VM (Ubuntu 22.04 or 24.04 LTS). |
| Network | Access to your router to set static IPs. |
| Domain | A reserved local domain (e.g., home.lan). |
1️⃣ The Network Foundation (MacVLAN)
Standard Docker networks hide containers behind the host. We want our core infrastructure (Nginx Proxy Manager, Pi‑hole) to have their own IP addresses on the physical network.
1.1 Prepare the Host
- Ensure your Ubuntu host has a static IP.
- In this guide we assume:
| Parameter | Value |
|---|---|
| Host Server IP | 192.168.1.137 |
| Gateway / Router | 192.168.1.1 |
| Interface Name | enp1s0 (check yours with ip a) |
1.2 Create the Docker MacVLAN Network
Reserve a small slice of IPs (e.g., .138 and .139) strictly for Docker to avoid conflicts.
# Create the network attached to your physical interface
sudo docker network create -d macvlan \
--subnet=192.168.1.0/24 \
--gateway=192.168.1.1 \
--ip-range=192.168.1.138/31 \
-o parent=enp1s0 \
npm_network
--ip-range strictly limits Docker to assigning only .138 and .139.
1.3 The “Host Shim” (The Critical Fix)
By design, a MacVLAN container cannot talk to its own host. To fix this we create a virtual bridge (shim).
1.3.1 Shim Script
Create a persistent startup script:
# /usr/local/bin/macvlan-shim.sh
#!/bin/bash
# Create the shim interface
ip link add npm-shim link enp1s0 type macvlan mode bridge
# Assign an IP to the host‑side of the shim (must be unique)
ip addr add 192.168.1.140/32 dev npm-shim
# Bring it up
ip link set npm-shim up
# Route traffic to the Docker MacVLAN range through the shim
ip route add 192.168.1.138/31 dev npm-shim
Make it executable:
sudo chmod +x /usr/local/bin/macvlan-shim.sh
1.3.2 Systemd Service
Create a systemd unit to run the shim on boot:
# /etc/systemd/system/macvlan-shim.service
[Unit]
Description=MacVLAN Shim for Docker Host Access
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/macvlan-shim.sh
[Install]
WantedBy=multi-user.target
Enable and start the service:
sudo systemctl enable --now macvlan-shim.service
2️⃣ The Identity Layer (Host‑Native Step‑CA)
We install the Certificate Authority directly on the OS (bare‑metal) for maximum stability.
2.1 Installation
# Step‑CA binary
wget https://dl.smallstep.com/gh-release/certificates/gh-release-header/v0.29.0/step-ca_0.29.0-1_amd64.deb
sudo apt install ./step-ca_0.29.0-1_amd64.deb
# Step‑CLI binary
wget https://dl.smallstep.com/gh-release/cli/gh-release-header/v0.29.0/step-cli_0.29.0-1_amd64.deb
sudo apt install ./step-cli_0.29.0-1_amd64.deb
2.2 Initialization
Initialize the CA to listen on port 9000:
step ca init --name "HomeLab-CA" \
--dns "ca.home.lan" \
--address ":9000" \
--provisioner "admin@home.lan"
Note: Save the generated password – you’ll need it for the service.
2.3 Create the Systemd Service
Store the password securely:
echo "YOUR_PASSWORD_HERE" | sudo tee /etc/step-ca/password.txt
sudo chmod 600 /etc/step-ca/password.txt
sudo chown step:step /etc/step-ca/password.txt
Create the service file:
# /etc/systemd/system/step-ca.service
[Unit]
Description=step-ca service
After=network.target
StartLimitIntervalSec=0
[Service]
Type=simple
User=step
Group=step
Environment=STEPPATH=/home/ubuntu/.step
ExecStart=/usr/bin/step-ca /home/ubuntu/.step/config/ca.json --password-file /etc/step-ca/password.txt
Restart=always
RestartSec=1
[Install]
WantedBy=multi-user.target
Enable and start the CA:
sudo systemctl daemon-reload
sudo systemctl enable --now step-ca
2.4 Generate the Wildcard Certificate
step ca certificate "*.home.lan" wildcard.crt wildcard.key
Keep these files safe – they will be uploaded to Nginx Proxy Manager later.
3️⃣ The Core Infrastructure (NPM & Pi‑hole)
Deploy the networking stack on the MacVLAN network using Docker Compose.
3.1 docker-compose.yml
version: '3.8'
services:
# --- Nginx Proxy Manager ---
npm:
image: jc21/nginx-proxy-manager:latest
container_name: npm
restart: unless-stopped
ports:
- "80:80"
- "81:81"
- "443:443"
volumes:
- ./npm-data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
npm_network:
ipv4_address: 192.168.1.138
# --- Pi‑hole DNS ---
pihole:
image: pihole/pihole:latest
container_name: pihole
restart: unless-stopped
environment:
TZ: "America/Chicago"
WEBPASSWORD: "securepassword"
volumes:
- ./pihole/etc-pihole:/etc/pihole
- ./pihole/etc-dnsmasq.d:/etc/dnsmasq.d
networks:
npm_network:
ipv4_address: 192.168.1.139
networks:
npm_network:
external: true
Deploy:
docker compose up -d
4️⃣ The Vault (Official Bitwarden)
We use the official Bitwarden install script. Because the script is aggressive, we run it on the host network (binding directly to ports) rather than forcing it into MacVLAN.
4.1 Install
curl -Lso bitwarden.sh https://go.btwrdn.co/bw-sh && chmod 700 bitwarden.sh
./bitwarden.sh install
- Domain:
bitwarden.home.lan - SSL: No (SSL will be terminated by Nginx Proxy Manager).
4.2 Configure Ports
Bitwarden defaults to ports 80/443, which conflict with NPM. Edit ./bwdata/config.yml:
http_port: 8080
https_port: # leave empty
4.3 Rebuild
./bitwarden.sh r
Bitwarden now listens on port 8080, allowing Nginx Proxy Manager to proxy it securely with the wildcard certificate you generated earlier.
🎉 What You’ve Got
| Component | IP (MacVLAN) | Role |
|---|---|---|
| npm | 192.168.1.138 | Reverse proxy / SSL termination |
| pihole | 192.168.1.139 | Network‑wide ad‑blocking DNS |
| step‑ca | Host‑only (port 9000) | Private PKI for all services |
| bitwarden | Host network, port 8080 | Password manager (proxied via NPM) |
All services are reachable only on your local network (or via Tailscale if you add it later), secured with valid, privately‑issued TLS certificates, and each container has its own physical IP for true isolation.
ebuild
./bitwarden.sh start
Bitwarden is now running on 192.168.1.137:8080.
5: Connecting the Dots
5.1 Pi‑hole DNS Records
- Open Pi‑hole.
- Go to Local DNS → DNS Records and add the following entries (NPM IP =
.138):
| Hostname | IP address |
|---|---|
bitwarden.home.lan | 192.168.1.138 |
portainer.home.lan | 192.168.1.138 |
npm.home.lan | 192.168.1.138 |
5.2 Nginx Proxy Manager Config
- Open NPM.
SSL
- SSL Certificates → Add Custom – upload your
wildcard.crtandwildcard.key.
Proxy Host (Bitwarden)
| Setting | Value |
|---|---|
| Domain | bitwarden.home.lan |
| Forward IP | 192.168.1.137 (the host IP) |
| Forward Port | 8080 |
| SSL | Custom Wildcard (Force SSL ON) |
Advanced – Fix Identity Errors
Add the following snippet in Advanced → Custom Nginx Configuration:
location / {
proxy_pass http://192.168.1.137:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
6: Remote Access (Tailscale)
To reach your private cloud from 5G, coffee shops, etc.:
Install Tailscale on the host
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --advertise-routes=192.168.1.0/24
Configure the Tailscale Console
- Admin → DNS
- Global Nameservers: add your Pi‑hole’s Tailscale IP (e.g.,
100.x.x.x). - Override Local DNS: enable (crucial!).
7: The “Samsung/Android” Fix
Android devices often bypass Pi‑hole. Apply all three steps:
- Disable Private DNS – Settings → Connections → More Connection Settings → Private DNS → OFF.
- Router Config – Disable IPv6 (Android prefers IPv6 DNS and will skip Pi‑hole if it’s enabled).
- Static Wi‑Fi Settings on the phone
- Set the Wi‑Fi connection to Static.
- Set DNS 1 and DNS 2 both to
192.168.1.139(Pi‑hole).
Conclusion
You now have a fully functional Private Cloud:
- Locally – Devices resolve
.landomains via Pi‑hole, which points to Nginx Proxy Manager serving valid SSL certificates. - Remotely – Tailscale tunnels DNS requests back home, giving the same experience as if you were on your couch.
- Security – You own the keys, the data, and the network.