DEV Community

Chaithanya Chowdhary
Chaithanya Chowdhary

Posted on

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

Author: Chaithanya Chowdhary Enugu
Date: December 31 2025
Level: Advanced

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

  • Hardware: A dedicated server/VM (Ubuntu 22.04/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

First, ensure your Ubuntu host has a static IP. In this guide, we assume:

  • 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

We will reserve a small slice of IPs (e.g., .138 and .139) strictly for Docker to avoid IP conflicts with other devices.

# 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
Enter fullscreen mode Exit fullscreen mode
  • --ip-range strictly limits Docker to assigning only .138 and .139.

1.3 The "Host Shim" (The Critical Fix)

By security design, a MacVLAN container cannot talk to its own Host. To fix this, we must create a virtual bridge (Shim).

Create a persistent startup script:
Create a file at /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
Enter fullscreen mode Exit fullscreen mode

Make it executable:

sudo chmod +x /usr/local/bin/macvlan-shim.sh
Enter fullscreen mode Exit fullscreen mode

Create a Systemd Service to run it on boot:
Create /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
Enter fullscreen mode Exit fullscreen mode

Enable the service:

sudo systemctl enable --now macvlan-shim.service
Enter fullscreen mode Exit fullscreen mode

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

Download and install the .deb packages.

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

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Note: Save the password generated here!

2.3 Create the Systemd Service

To keep the CA running 24/7.

  1. Save 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
Enter fullscreen mode Exit fullscreen mode
  1. 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
Enter fullscreen mode Exit fullscreen mode
  1. Start the CA:
sudo systemctl daemon-reload
sudo systemctl enable --now step-ca
Enter fullscreen mode Exit fullscreen mode

2.4 Generate the Wildcard Certificate

This single certificate will secure all your services.

step ca certificate "*.home.lan" wildcard.crt wildcard.key
Enter fullscreen mode Exit fullscreen mode

(Keep these files safe; we will upload them to Nginx Proxy Manager later.)


3: The Core Infrastructure (NPM & Pi-hole)

We use Docker Compose to deploy the networking stack on the MacVLAN network.

File: 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
Enter fullscreen mode Exit fullscreen mode

Run docker-compose up -d to deploy.


4: The Vault (Official Bitwarden)

For the password manager, we use the official install script. Because this script is aggressive, we let it run on the Host Network (binding to ports) rather than forcing it into MacVLAN.

Follow this guide to self-host bitwarden bitwarden.com

4.1 Install

curl -Lso bitwarden.sh https://go.btwrdn.co/bw-sh && chmod 700 bitwarden.sh
./bitwarden.sh install
Enter fullscreen mode Exit fullscreen mode
  • Domain: bitwarden.home.lan
  • SSL: No (We handle SSL at Nginx Proxy Manager).

4.2 Configure Ports

Bitwarden defaults to 80/443, which conflicts with NPM. We must move it.
Edit ./bwdata/config.yml:

  • http_port: 8080
  • https_port: (Leave empty)

4.3 Rebuild

./bitwarden.sh rebuild
./bitwarden.sh start
Enter fullscreen mode Exit fullscreen mode

Bitwarden is now running on 192.168.1.137:8080.


5: Connecting the Dots

5.1 Pi-hole DNS Records

Open Pi-hole (http://192.168.1.139/admin). Go to Local DNS > DNS Records.
Map all your domains to the NPM IP (.138):

  • 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 (http://192.168.1.138:81).

  1. SSL: Go to SSL Certificates > Add Custom > Upload your wildcard.crt and wildcard.key.
  2. Proxy Host (Bitwarden):
  3. Domain: bitwarden.home.lan
  4. Forward IP: 192.168.1.137 (The Host IP)
  5. Forward Port: 8080
  6. SSL: Custom Wildcard (Force SSL ON).
  7. Advanced: Add this code to fix Identity errors:
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;
}
Enter fullscreen mode Exit fullscreen mode

6: Remote Access (Tailscale)

To access this private cloud from 5G/Coffee Shops:

  1. Install Tailscale on Host:
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --advertise-routes=192.168.1.0/24
Enter fullscreen mode Exit fullscreen mode
  1. Configure Console:
  2. Go to Tailscale Admin > DNS.
  3. Global Nameservers: Add your Pi-hole's Tailscale IP (100.x.x.x).
  4. Override Local DNS: Enable (Crucial!).

7: The "Samsung/Android" Fix

Android devices are stubborn. Even with the setup above, they will try to bypass your Pi-hole.

  1. Disable Private DNS: Android Settings > Connections > More Connection Settings > Private DNS > OFF.
  2. Router Config: Log into your physical router and Disable IPv6. (Android prefers IPv6 DNS and will bypass your Pi-hole if this is on).
  3. Static WiFi Settings: On the phone, set the WiFi connection to Static and set DNS 1 and DNS 2 both to 192.168.1.139.

Conclusion

You now have a fully functional Private Cloud.

  • Locally: Devices use Pi-hole to resolve .lan domains to Nginx, which serves valid SSL.
  • Remotely: Tailscale tunnels your DNS requests back home, providing the exact same experience as if you were sitting on your couch.
  • Security: You own the keys, the data, and the network.

Top comments (0)