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 withip 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
-
--ip-rangestrictly limits Docker to assigning only.138and.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
Make it executable:
sudo chmod +x /usr/local/bin/macvlan-shim.sh
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
Enable 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
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
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 password generated here!
2.3 Create the Systemd Service
To keep the CA running 24/7.
- 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
-
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
- Start the CA:
sudo systemctl daemon-reload
sudo systemctl enable --now step-ca
2.4 Generate the Wildcard Certificate
This single certificate will secure all your services.
step ca certificate "*.home.lan" wildcard.crt wildcard.key
(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
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
-
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
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).
-
SSL: Go to SSL Certificates > Add Custom > Upload your
wildcard.crtandwildcard.key. - Proxy Host (Bitwarden):
-
Domain:
bitwarden.home.lan -
Forward IP:
192.168.1.137(The Host IP) -
Forward Port:
8080 - SSL: Custom Wildcard (Force SSL ON).
- 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;
}
6: Remote Access (Tailscale)
To access this private cloud from 5G/Coffee Shops:
- Install Tailscale on Host:
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --advertise-routes=192.168.1.0/24
- Configure Console:
- Go to Tailscale Admin > DNS.
- Global Nameservers: Add your Pi-hole's Tailscale IP (100.x.x.x).
- 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.
- Disable Private DNS: Android Settings > Connections > More Connection Settings > Private DNS > OFF.
- Router Config: Log into your physical router and Disable IPv6. (Android prefers IPv6 DNS and will bypass your Pi-hole if this is on).
-
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
.landomains 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)