Building a zero-trust overlay network with Nebula

A report on connecting a VPS, a Debian homelab server, macOS and iOS, all without losing your mind.
1. The problem
Every self-hoster eventually arrives at the same crossroads: you have multiple machines scattered across the internet, a home network full of IoT devices you're slightly embarrassed about, and a pressing need to tie it all together without exposing port 22 to the entire planet.
The requirements going into this project were clear:
- A VPS running Alpine Linux with a modest Docker stack (2GB RAM, 100GB NVMe)
- A Debian-based homelab server on the home network (referred to as "homeserver" throughout this article)
- A laptop and a phone that need access to everything, everywhere
- Guest devices that should reach exactly one thing and nothing else
- No commercial VPN services; privacy theatre at best, a single point of failure at worst
After evaluating a handful of options, the decision landed on Nebula. It's built on the same Noise Protocol Framework as WireGuard, was developed originally at Slack, and, crucially, has a certificate-based group model that makes per-device firewall rules trivial to reason about.
2. Why Nebula over wireGuard
WireGuard is faster. It lives in the kernel, it's auditable in a weekend, and ChaCha20-Poly1305 is hard to argue with. So why Nebula?
The difference comes down to operational model. WireGuard requires manual peer configuration on every node. When you add a new device, you edit config files everywhere. Nebula uses a lighthouse (a central node with a routable IP) that handles peer discovery. Add a new device, generate a certificate, distribute it, done. The rest of the network figures it out automatically.
The second reason is the group-based firewall. In Nebula, groups are burned into certificates at signing time. A guest device with group guest cannot lie about its group, it doesn't have the CA key. On WireGuard, access control lives in iptables rules and the honor system.
The trade-off: Nebula runs in userspace, which costs you some throughput. For a home network accessed by a handful of devices over residential internet, the bottleneck is never going to be Nebula's userspace overhead. It'll be your ISP.
Battery life note: WireGuard wins on iOS battery consumption due to kernel-level efficiency. For a personal VPN that's always-on on your phone, this is worth considering. For occasional access to home devices, the difference is negligible.
3. Network design
The Nebula overlay network uses the 10.100.100.0/24 range, keeping it well away from common home LAN ranges and the homelab network (10.x.x.0/24 in this case). Of course you can adjust this accordingly.
10.100.100.1 – vps-lighthouse (VPS, lighthouse)
10.100.100.2 – laptop (macOS client)
10.100.100.3 – mobile (iOS client)
10.100.100.5 – homeserver (Debian, subnet gateway for home LAN)
10.100.100.10+ – guest devices (restricted group)
The primary group can reach everything. Guest devices, signed with a guest group certificate, can only reach specific ports and nothing else. This is enforced at the Nebula firewall level, not iptables, which means it follows the tunnel regardless of which host the traffic is destined for.
Homeserver acts as a subnet router, advertising the home LAN subnet to the rest of the network. This allows the VPS, laptop, and phone to reach anything on the home LAN; meters, APs, cameras, printers, without those devices needing Nebula installed. They remain blissfully unaware of the overlay network while homeserver handles the NAT.
4. Setting up the Certificate Authority
Nebula provides a single binary, nebula-cert, for CA management. On Alpine:
apk add nebula
Create the config directory with appropriately paranoid permissions:
mkdir -p /etc/nebula
chmod 700 /etc/nebula
cd /etc/nebula
Generate the CA with a 5-year validity. The default is 1 year, which means you'll be doing this again before you've had time to forget how:
nebula-cert ca -name "your-network-name" -duration 43800h
This produces ca.crt (public, distribute freely within the network) and ca.key (private, never leaves the VPS, ever). The ca.key is the skeleton key for your entire network. Treat it accordingly.
⚠ Set a calendar reminder for ~30 days before expiry. Better yet, automate it; see the monitoring section below.
Sign certificates for each node. Groups are embedded at signing time and cannot be modified without a new certificate:
# Lighthouse
nebula-cert sign -name "vps-lighthouse" -ip "10.100.100.1/24" -groups "yourgroup"
# Home server with subnet routing
nebula-cert sign -name "homeserver" -ip "10.100.100.5/24" -groups "yourgroup" -subnets "10.x.x.0/24"
# Regular clients
nebula-cert sign -name "laptop" -ip "10.100.100.2/24" -groups "yourgroup"
nebula-cert sign -name "mobile" -ip "10.100.100.3/24" -groups "yourgroup"
# Guest device — restricted group
nebula-cert sign -name "guest-device" -ip "10.100.100.10/24" -groups "guest"
To revoke a certificate before expiry, add its fingerprint to the PKI blocklist in your config. Get the fingerprint with:
nebula-cert print -path /etc/nebula/device.crt
5. Lighthouse configuration
The lighthouse config on the VPS. Note the relay configuration, Nebula will attempt direct peer-to-peer tunnels first (using UDP hole punching) and fall back to relay only when direct connections genuinely fail. Having the lighthouse act as relay costs you some bandwidth but saves you from debugging why a device behind a symmetric NAT can't reach anything.
pki:
ca: /etc/nebula/ca.crt
cert: /etc/nebula/vps-lighthouse.crt
key: /etc/nebula/vps-lighthouse.key
static_host_map: {}
lighthouse:
am_lighthouse: true
listen:
host: 0.0.0.0
port: 4242
punchy:
punch: true
relay:
am_relay: true
use_relays: true
tun:
disabled: false
dev: nebula1
drop_local_broadcast: false
drop_multicast: false
unsafe_routes:
- route: 10.x.x.0/24
via: 10.100.100.5
logging:
level: info
format: text
firewall:
outbound:
- port: any
proto: any
host: any
inbound:
- port: any
proto: any
group: yourgroup
# Uncomment when adding guest devices:
# - port: 445
# proto: tcp
# group: guest
6. OpenRC Service and the capability problem
This is where things got interesting. Nebula on Alpine installs with an OpenRC init script that, by default, drops CAP_NET_ADMIN from the nebula process. CAP_NET_ADMIN is required to create TUN interfaces. The relevant line in /etc/init.d/nebula:
capabilities="^cap_net_admin"
The ^ character means "remove this capability". Which is arguably the wrong default for a networking daemon that needs to create network interfaces, but here we are. Fix it:
sed -i 's/capabilities="^cap_net_admin"/capabilities="cap_net_admin"/' /etc/init.d/nebula
Additionally, the init script expects /etc/nebula/nebula.yml, not config.yml. Rename accordingly:
mv /etc/nebula/config.yml /etc/nebula/nebula.yml
The service also runs as a nebula user by default, which cannot read certificate files owned by root. Either chown the relevant files or remove the command_user line from the init script:
# Option A: chown cert files to nebula user
chown nebula:nebula /etc/nebula/ca.crt /etc/nebula/vps-lighthouse.crt /etc/nebula/vps-lighthouse.key
# Option B: remove user restriction (runs as root)
sed -i 's/^command_user=.*//' /etc/init.d/nebula
Enable and start:
rc-update add nebula default
rc-service nebula start
ip addr show nebula1
✓ Ensure the TUN module survives reboots:
echo "tun" >> /etc/modules
7. iptables rules
The VPS runs a DROP-default iptables policy, which means you need explicit rules for both the Nebula UDP port and traffic on the nebula1 interface. Missing the second rule is a common gotcha; the port being open doesn't mean the kernel will route traffic arriving on the TUN interface correctly.
# Allow Nebula handshake traffic
iptables -A INPUT -p udp --dport 4242 -j ACCEPT -m comment --comment "Nebula VPN"
# Allow all traffic on the nebula interface
iptables -A INPUT -i nebula1 -j ACCEPT
# Optional: UDP 443 fallback for strict firewalls
iptables -t nat -A PREROUTING -p udp --dport 443 -j REDIRECT --to-port 4242
# Save
iptables-save > /etc/iptables/rules-save
The UDP 443 redirect is worth having. Some corporate and guest networks block all non-standard UDP ports. Since nginx is already serving HTTPS on TCP 443, there's no conflict; TCP and UDP are separate protocol stacks. Nebula clients can be configured with multiple lighthouse addresses:
static_host_map:
"10.100.100.1": ["your.domain.com:4242", "your.domain.com:443"]
8. Client configurations
8.1 macOS
Install via Homebrew:
brew install nebula
Copy certificates from the VPS:
mkdir -p ~/nebula
scp root@your.domain.com:/etc/nebula/ca.crt ~/nebula/
scp root@your.domain.com:/etc/nebula/laptop.crt ~/nebula/
scp root@your.domain.com:/etc/nebula/laptop.key ~/nebula/
Client config for macOS. Note: macOS ignores the dev name and uses utun[n] instead — this is expected and not a problem:
pki:
ca: /Users/username/nebula/ca.crt
cert: /Users/username/nebula/laptop.crt
key: /Users/username/nebula/laptop.key
static_host_map:
"10.100.100.1": ["your.domain.com:4242", "your.domain.com:443"]
lighthouse:
am_lighthouse: false
hosts:
- "10.100.100.1"
listen:
host: 0.0.0.0
port: 4242
punchy:
punch: true
relay:
am_relay: false
use_relays: true
tun:
disabled: false
dev: nebula1
drop_local_broadcast: false
drop_multicast: false
unsafe_routes:
- route: 10.x.x.0/24
via: 10.100.100.5
logging:
level: info
format: text
firewall:
outbound:
- port: any
proto: any
host: any
inbound:
- port: any
proto: any
group: yourgroup
Run as a LaunchDaemon so it starts at boot without requiring login:
<!-- /Library/LaunchDaemons/net.example.nebula.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>net.example.nebula</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/nebula</string>
<string>-config</string>
<string>/Users/username/nebula/config.yml</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/var/log/nebula.log</string>
<key>StandardErrorPath</key>
<string>/var/log/nebula.log</string>
</dict>
</plist>
sudo launchctl load /Library/LaunchDaemons/net.example.nebula.plist
sudo launchctl start net.example.nebula
Useful management commands:
sudo launchctl stop net.example.nebula # Stop
sudo launchctl start net.example.nebula # Start
sudo launchctl list | grep nebula # Status
sudo launchctl unload /Library/LaunchDaemons/net.example.nebula.plist # Disable
8.2 iOS
The official Nebula iOS app from Defined Networking (available on the App Store) works well but has some quirks that cost time.
The app exports and imports configurations as YAML files, but it rewrites them on import using its own schema; adding fields you didn't ask for and, critically, setting inbound: [] which blocks all inbound connections. Since Nebula requires a bidirectional handshake to establish a tunnel, an empty inbound list means the device can never complete a tunnel to any peer other than the lighthouse.
The workaround: embed all certificates inline in the YAML before importing, and add the inbound firewall rule in the same file. The app will reformat the YAML but preserves inline certificate content and firewall rules if they're present at import time.
Generate the import-ready YAML from your Mac:
cat > ~/nebula/mobile.yml << EOF
pki:
ca: |
$(cat ~/nebula/ca.crt | sed 's/^/ /')
cert: |
$(cat ~/nebula/mobile.crt | sed 's/^/ /')
key: |
$(cat ~/nebula/mobile.key | sed 's/^/ /')
static_host_map:
"10.100.100.1": ["your.domain.com:4242", "your.domain.com:443"]
lighthouse:
am_lighthouse: false
hosts:
- "10.100.100.1"
punchy:
punch: true
relay:
am_relay: false
use_relays: true
tun:
disabled: false
unsafe_routes:
- route: 10.x.x.0/24
via: 10.100.100.5
logging:
level: info
format: text
firewall:
outbound:
- port: any
proto: any
host: any
inbound:
- port: any
proto: any
group: yourgroup
EOF
Transfer via AirDrop to the phone and import in the Nebula app via "Add site from file". Validate the YAML first to avoid cryptic import errors:
python3 -c "import yaml; yaml.safe_load(open('mobile.yml'))" && echo "Valid"
9. Homeserver: Debian subnet router
Debian packages Nebula as a systemd template service, not a direct service. The invocation is different from what you might expect:
apt install nebula
# Config goes in /etc/nebula/nebula.yml (not config.yml)
mv /etc/nebula/config.yml /etc/nebula/nebula.yml
# Service name uses the config filename as a template parameter
systemctl enable nebula@nebula
systemctl start nebula@nebula
Enable IP forwarding — without this, packets arrive at homeserver from the Nebula tunnel and go nowhere:
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
sysctl -p
Add a masquerade rule so LAN devices see traffic coming from homeserver's own IP. Check your interface name first. (It may not be eth0):
ip route | grep default
# default via 10.x.x.1 dev eno1 ...
iptables -t nat -A POSTROUTING -s 10.100.100.0/24 -o eno1 -j MASQUERADE
apt install iptables-persistent -y
netfilter-persistent save
Homeserver's Nebula config. Note: unsafe_routes in homeserver's own config are not needed, homeserver IS the gateway. The routes belong in the configs of the other nodes, pointing via 10.100.100.5:
pki:
ca: /etc/nebula/ca.crt
cert: /etc/nebula/homeserver.crt
key: /etc/nebula/homeserver.key
static_host_map:
"10.100.100.1": ["your.domain.com:4242", "your.domain.com:443"]
lighthouse:
am_lighthouse: false
hosts:
- "10.100.100.1"
listen:
host: 0.0.0.0
port: 4242
punchy:
punch: true
relay:
am_relay: false
use_relays: true
tun:
disabled: false
dev: nebula1
logging:
level: info
format: text
firewall:
outbound:
- port: any
proto: any
host: any
inbound:
- port: any
proto: any
group: yourgroup
⚠ Homeserver must have an explicit inbound firewall rule. Without it, it will refuse handshakes from peers. A node with no inbound rules is not "open by default" — it's closed. This is correct and safe, but it will confuse you at 23.00 hours when the tunnel won't come up. Don't ask me how I know.
10. Certificate expiry monitoring
With a 5-year CA, you have time on your side. That's also plenty of time to completely forget this setup exists until everything breaks on a Sunday. Add a daily cron job:
#!/bin/sh
# /usr/local/bin/nebula-cert-check.sh
CERT="/etc/nebula/ca.crt"
EXPIRY=$(nebula-cert print -path $CERT | grep "Not After" | sed 's/Not After: //' | awk '{print $1, $2}')
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( ($EXPIRY_EPOCH - $NOW_EPOCH) / 86400 ))
if [ "$DAYS_LEFT" -lt 30 ]; then
echo "Subject: Nebula CA expires in ${DAYS_LEFT} days
From: monitoring@your.domain.com
To: you@example.com
Your Nebula CA certificate expires in ${DAYS_LEFT} days.
Renew the CA and all node certificates before the deadline.
" | ssmtp you@example.com
fi
chmod +x /usr/local/bin/nebula-cert-check.sh
echo "0 9 * * * /usr/local/bin/nebula-cert-check.sh" >> /etc/crontabs/root
For sending mail from Alpine without a full MTA, ssmtp with any SMTP relay works fine:
apk add ssmtp
# /etc/ssmtp/ssmtp.conf
root=you@example.com
mailhub=smtp.yourprovider.com:465
AuthUser=you@example.com
AuthPass=your-app-password
UseTLS=YES
FromLineOverride=YES
hostname=your.domain.com
chmod 600 /etc/ssmtp/ssmtp.conf
11. SSH Login Notifications
Worth adding while you're at it, a shell snippet that emails on every interactive login:
#!/bin/sh
# /etc/profile.d/login-notify.sh
echo "Subject: Login on $(hostname)
From: monitoring@your.domain.com
To: you@example.com
Login notification:
User : $(whoami)
Date/time : $(date)
Source IP : $(echo $SSH_CONNECTION | awk '{print $1}')
" | ssmtp you@example.com
chmod +x /etc/profile.d/login-notify.sh
12. Monitoring active connections
Nebula doesn't have a "show connected peers" command unless you enable the built-in SSH debug interface. For most purposes, grepping the log is sufficient:
# Recent successful tunnel establishments
grep "Handshake message received" /var/log/nebula.log | tail -20
# Tunnel teardowns
grep -E "Close tunnel|Handshake timed out" /var/log/nebula.log | tail -20
# Live feed of tunnel events
tail -f /var/log/nebula.log | grep -E "Handshake message received|Close tunnel"
Each handshake log line includes certName (who connected), vpnIp (their Nebula address), and udpAddr (their real public IP and port). On Debian with systemd, use journalctl instead:
journalctl -u nebula@nebula -f | grep Handshake
13. Collected gotchas
A summary of issues encountered during this setup, for the benefit of anyone following the same path.
Alpine: CAP_NET_ADMIN stripped by default
The Alpine OpenRC init script for Nebula strips CAP_NET_ADMIN, preventing TUN device creation. The nebula process starts successfully (OpenRC shows [ ok ]) but no nebula1 interface appears. Running nebula directly as root works fine, which is the clue. Fix: remove the ^ prefix from the capabilities line in /etc/init.d/nebula.
Alpine: Wrong config filename
The init script looks for nebula.yml, not config.yml. The first run creates an empty nebula.yml and logs "failed to load config" in a loop. Rename your config file to match.
iptables: Missing INPUT rule for nebula1
With a DROP-default INPUT policy, having UDP 4242 open is not enough. Traffic arriving on the nebula1 TUN interface also needs an explicit ACCEPT rule. Without it, pings to the lighthouse's own Nebula IP time out while everything looks configured correctly.
Homeserver: Wrong interface name in MASQUERADE rule
iptables MASQUERADE rules require the correct outbound interface name. On modern Debian systems this is frequently eno1, ens3, or similar, not eth0. Check with ip route | grep default before writing the rule.
iOS app: inbound: [] kills all peer tunnels
The Nebula iOS app sets inbound: [] in exported configs. This prevents any peer other than the lighthouse from completing a handshake with the device. Peers will show no tunnel activity in logs, they don't even attempt the handshake because the lighthouse can't facilitate it. Fix: include inbound rules in the YAML before importing unless I was missing something.
Debian: Template service naming
The Debian Nebula package uses systemd template units: nebula@.service. You invoke it as nebula@nebula (which loads /etc/nebula/nebula.yml). Not nebula.service => that doesn't exist.
All certificates need a group
If the lighthouse certificate is signed without a group, and your inbound rules only allow specific groups, the lighthouse's own traffic will be blocked by clients. Sign all certificates, including the lighthouse, with appropriate group membership.
Homeserver inbound rule is mandatory
Even though tunnel establishment is initiated by the client, Nebula's handshake is bidirectional. If homeserver has no inbound firewall rules, it silently refuses handshakes from peers. The symptom: no log activity on homeserver whatsoever when a client tries to connect. The fix: add an explicit inbound rule allowing your group.
14. A Note on TUN vs TAP
Nebula uses TUN (layer 3 tunneling). This means it routes IP packets, which covers everything you'd want for this use case. TAP (layer 2) tunnels Ethernet frames, including broadcasts, ARP, and non-IP protocols. TAP is useful for site-to-site VPNs where you want two physical networks to appear as a single flat layer-2 domain, devices on one side can ARP for devices on the other, DHCP works across the tunnel, and so on.
For connecting individual devices and exposing subnets via routing (exactly what unsafe_routes does), TUN is the right choice and brings no meaningful limitation.
15. Housekeeping
Once certificates are distributed to their respective devices, there's no operational reason to keep client certificates on the VPS. The CA key and the lighthouse's own certificate are all that's needed for day-to-day operation:
# Keep: ca.crt, ca.key, vps-lighthouse.crt, vps-lighthouse.key
# Remove after distributing to devices:
rm /etc/nebula/mobile.crt /etc/nebula/mobile.key
rm /etc/nebula/laptop.crt /etc/nebula/laptop.key
rm /etc/nebula/homeserver.crt /etc/nebula/homeserver.key
⚠ Back up
ca.keyto offline storage. If it's lost, every certificate in the network needs to be regenerated from scratch. This is the kind of thing that seems obvious until it's 2 in the morning and something goes wrong.
16. Conclusion
Nebula is not the simplest VPN solution, WireGuard's "generate a keypair, exchange public keys, add to config" model is hard to beat for a two-node setup. But for networks with more than a handful of devices, varying trust levels, and devices that come and go, Nebula's certificate-based groups and automatic peer discovery pay for the operational overhead many times over.
The Alpine + OpenRC combination introduced friction that wouldn't exist on Debian or Fedora, but nothing insurmountable. The iOS app is functional with caveats around config handling. Subnet routing via a home server works reliably once the MASQUERADE rule and IP forwarding are both in place, which are two things that are easy to forget independently.
The result: a fully functional overlay network connecting a VPS, a laptop, a phone, and a home server, with transparent access to every device on the home LAN. Guest devices can reach exactly what you allow them to reach. Everything runs on hardware that was already paid for.
Which, when you put it that way, is exactly what self-hosting is supposed to feel like.