The Zero-Trust Homelab Manual: MacVLAN, Private PKI, and Tailscale

Published: (December 31, 2025 at 12:37 PM EST)
5 min read
Source: Dev.to

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

ItemDetails
HardwareA dedicated server/VM (Ubuntu 22.04 or 24.04 LTS).
NetworkAccess to your router to set static IPs.
DomainA 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

  1. Ensure your Ubuntu host has a static IP.
  2. In this guide we assume:
ParameterValue
Host Server IP192.168.1.137
Gateway / Router192.168.1.1
Interface Nameenp1s0 (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

ComponentIP (MacVLAN)Role
npm192.168.1.138Reverse proxy / SSL termination
pihole192.168.1.139Network‑wide ad‑blocking DNS
step‑caHost‑only (port 9000)Private PKI for all services
bitwardenHost network, port 8080Password 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

  1. Open Pi‑hole.
  2. Go to Local DNS → DNS Records and add the following entries (NPM IP = .138):
HostnameIP address
bitwarden.home.lan192.168.1.138
portainer.home.lan192.168.1.138
npm.home.lan192.168.1.138

5.2 Nginx Proxy Manager Config

  1. Open NPM.

SSL

  • SSL Certificates → Add Custom – upload your wildcard.crt and wildcard.key.

Proxy Host (Bitwarden)

SettingValue
Domainbitwarden.home.lan
Forward IP192.168.1.137 (the host IP)
Forward Port8080
SSLCustom 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

  1. Admin → DNS
  2. Global Nameservers: add your Pi‑hole’s Tailscale IP (e.g., 100.x.x.x).
  3. Override Local DNS: enable (crucial!).

7: The “Samsung/Android” Fix

Android devices often bypass Pi‑hole. Apply all three steps:

  1. Disable Private DNS – Settings → Connections → More Connection Settings → Private DNS → OFF.
  2. Router Config – Disable IPv6 (Android prefers IPv6 DNS and will skip Pi‑hole if it’s enabled).
  3. 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 .lan domains 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.
Back to Blog

Related posts

Read more »

AI SEO agencies Nordic

!Cover image for AI SEO agencies Nordichttps://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads...