---
title: NordVPN / WireGuard in LXC
type: runbook
tags:
  - vpn
aliases: []
related:
  - [[ct-runbook]]
  - [[meshtasticd-sim-nodes-runbook]]
  - [[proxmox-onboard-node]]
  - [[headscale-onboard-node]]
  - [[peertube-remote-runner]]
updated: 2026-06-18
---
# NordVPN / WireGuard in LXC

Set up VPN with IP rotation inside an LXC container. Handles the LXC-specific gotchas: TUN device, systemd compatibility, split tunneling so local [[services]] stay reachable.

---

## Prerequisites

- LXC container provisioned and running (see `ct-runbook.md`)
- SSH access to both the Proxmox host and the container
- NordVPN account with a service token (from https://my.nordaccount.com/dashboard/nordvpn/access-tokens/)

---

## Inputs

Prompt the user for all of these before executing:

```
CTID=                   # Container ID on Proxmox host
CT_HOST=                # SSH alias or IP for the container
PVE_HOST=               # SSH alias or IP for the Proxmox host
NORDVPN_TOKEN=          # NordVPN service token
VPN_COUNTRIES=          # Comma-separated rotation list (e.g., "United_States,Canada,United_Kingdom,Germany,Netherlands,Sweden")
VPN_CONFIG_DIR=         # Where to store WireGuard configs inside CT (e.g., /opt/vpn)
```

---

## Step 1: Enable TUN Device on Container

**Run on Proxmox host.** LXC containers don't have `/dev/net/tun` by default — VPN won't work without it.

```bash
ssh $PVE_HOST "grep -q 'dev/net/tun' /etc/pve/lxc/${CTID}.conf 2>/dev/null || {
  echo 'lxc.cgroup2.devices.allow: c 10:200 rwm' >> /etc/pve/lxc/${CTID}.conf
  echo 'lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file' >> /etc/pve/lxc/${CTID}.conf
  echo 'TUN device added — container restart required'
}"
```

If lines were added, restart the container:

```bash
ssh $PVE_HOST "pct reboot $CTID"
```

### Gate

```bash
ssh $CT_HOST 'ls -la /dev/net/tun'
```

Must show the device. If missing, the cgroup/mount entries didn't take — check `/etc/pve/lxc/${CTID}.conf`.

---

## Step 2: Try NordVPN CLI (Option A)

The CLI is the simplest path but requires working systemd in the container (which most LXCs have, but some stripped-down templates don't).

```bash
ssh $CT_HOST 'sh <(curl -sSf https://downloads.nordcdn.com/apps/linux/install.sh)'
```

If the installer completes without errors:

```bash
ssh $CT_HOST "nordvpn login --token $NORDVPN_TOKEN"
ssh $CT_HOST 'nordvpn set technology nordlynx'      # WireGuard-based, faster
ssh $CT_HOST 'nordvpn set killswitch off'            # Don't kill local services
ssh $CT_HOST 'nordvpn set autoconnect off'           # We control rotation
ssh $CT_HOST 'nordvpn set dns off'                   # Keep container's DNS
```

### Test

```bash
ssh $CT_HOST 'nordvpn connect United_States && sleep 3 && curl -s https://ifconfig.me && nordvpn disconnect'
```

Must show a non-local IP. If it does, **skip to Step 4** (rotation script).

### Common failures

- **"Whoops! /run/nordvpn/nordvpnd.sock not found"** — nordvpnd service didn't start. Check `systemctl status nordvpnd`. If systemd is broken in this LXC, fall through to Option B.
- **"Permission denied creating /dev/net/tun"** — Step 1 TUN device not configured. Go back.
- **Installer hangs on "Starting NordVPN daemon"** — systemd issue. Kill it, fall through to Option B.

---

## Step 3: WireGuard Manual Configs (Option B — Fallback)

Use this if NordVPN CLI doesn't work in the LXC.

### Install WireGuard

```bash
ssh $CT_HOST 'apt install -y wireguard-tools curl jq'
```

### Generate NordVPN WireGuard configs

NordVPN provides WireGuard configs via their API. Generate one per country:

```bash
ssh $CT_HOST "mkdir -p $VPN_CONFIG_DIR"

# Get NordVPN WireGuard private key
# Method: Use the NordVPN API with your token to get credentials
# This requires the nordvpn CLI to extract the private key, OR manual setup:
#
# 1. Go to https://my.nordaccount.com/dashboard/nordvpn/manual-configuration/
# 2. Generate WireGuard credentials
# 3. Download configs for each country
# 4. SCP them to the container

# Place configs as: $VPN_CONFIG_DIR/us.conf, ca.conf, uk.conf, de.conf, nl.conf, se.conf
```

**⚠️ Manual step required:** NordVPN's WireGuard config generation requires either the CLI (which didn't work) or manual download from the NordVPN dashboard. Download `.conf` files for each country in the rotation list and SCP them to the container.

### Config format

Each `.conf` file should look like:

```ini
[Interface]
PrivateKey = <your-wireguard-private-key>
Address = 10.5.0.2/16
DNS = 103.86.96.100

[Peer]
PublicKey = <server-public-key>
AllowedIPs = 0.0.0.0/0
Endpoint = <server-ip>:51820
PersistentKeepalive = 25
```

**Critical for LXC:** If the container runs services that must stay reachable on the local network (e.g., PeerTube on port 9000), you need split tunneling. Replace `AllowedIPs = 0.0.0.0/0` with specific routes that exclude your LAN:

```ini
# Route everything EXCEPT local network through VPN
AllowedIPs = 0.0.0.0/1, 128.0.0.0/1
# This covers all IPs but lets 192.168.x.x and 100.64.x.x traffic stay local
```

Or more precisely, exclude your subnets:

```bash
# Generate AllowedIPs that exclude local networks
# This sends all traffic through VPN except 192.168.1.0/24 and 100.64.0.0/10
AllowedIPs = 0.0.0.0/5, 8.0.0.0/7, 11.0.0.0/8, 12.0.0.0/6, 16.0.0.0/4, 32.0.0.0/3, 64.0.0.0/3, 96.0.0.0/6, 100.0.0.0/10, 100.128.0.0/9, 101.0.0.0/8, 102.0.0.0/7, 104.0.0.0/5, 112.0.0.0/4, 128.0.0.0/3, 160.0.0.0/5, 168.0.0.0/6, 172.0.0.0/8, 173.0.0.0/8, 174.0.0.0/7, 176.0.0.0/4, 192.0.0.0/9, 192.128.0.0/11, 192.160.0.0/13, 192.169.0.0/16, 192.170.0.0/15, 192.172.0.0/14, 192.176.0.0/12, 192.192.0.0/10, 193.0.0.0/8, 194.0.0.0/7, 196.0.0.0/6, 200.0.0.0/5, 208.0.0.0/4, 224.0.0.0/3
```

**Simpler alternative:** Use `wg-quick` post-up/down scripts to manage routes:

```ini
[Interface]
PrivateKey = <key>
Address = 10.5.0.2/16
PostUp = ip route add 192.168.1.0/24 via $(ip route show default | awk '{print $3}') dev eth0
PostUp = ip route add 100.64.0.0/10 via $(ip route show default | awk '{print $3}') dev eth0
PreDown = ip route del 192.168.1.0/24 via $(ip route show default | awk '{print $3}') dev eth0 2>/dev/null; true
PreDown = ip route del 100.64.0.0/10 via $(ip route show default | awk '{print $3}') dev eth0 2>/dev/null; true

[Peer]
PublicKey = <key>
AllowedIPs = 0.0.0.0/0
Endpoint = <server>:51820
```

### Test

```bash
ssh $CT_HOST "wg-quick up $VPN_CONFIG_DIR/us.conf && sleep 2 && curl -s https://ifconfig.me && echo && wg-quick down $VPN_CONFIG_DIR/us.conf"
```

Must show a NordVPN IP. Verify local services still reachable:

```bash
# From another machine on the LAN, while VPN is up:
curl -s http://<CT_LOCAL_IP>:<SERVICE_PORT>/  # Must still respond
```

---

## Step 4: VPN Rotation Helper Script

Regardless of Option A or B, create a rotation script that other services can call.

```bash
ssh $CT_HOST "cat > $VPN_CONFIG_DIR/vpn-rotate.sh << 'SCRIPT'
#!/bin/bash
# VPN Rotation Script
# Usage: vpn-rotate.sh [connect|disconnect|rotate|status]

CONFIG_DIR=\"$VPN_CONFIG_DIR\"
STATE_FILE=\"$VPN_CONFIG_DIR/vpn-state.json\"
COUNTRIES=($VPN_COUNTRIES)

# Detect VPN method
if command -v nordvpn &>/dev/null && systemctl is-active --quiet nordvpnd 2>/dev/null; then
    VPN_METHOD=nordvpn
else
    VPN_METHOD=wireguard
fi

get_current() {
    if [ \"\$VPN_METHOD\" = \"nordvpn\" ]; then
        nordvpn status 2>/dev/null | grep -i country | awk '{print \$NF}'
    else
        wg show 2>/dev/null | head -1 | awk '{print \$2}' | sed 's/.conf//'
    fi
}

get_public_ip() {
    curl -s --connect-timeout 5 https://ifconfig.me 2>/dev/null
}

vpn_connect() {
    local country=\${1:-\${COUNTRIES[0]}}
    echo \"Connecting to \$country...\"
    if [ \"\$VPN_METHOD\" = \"nordvpn\" ]; then
        nordvpn connect \"\$country\"
    else
        # Disconnect any existing
        for conf in \$CONFIG_DIR/*.conf; do
            wg-quick down \"\$conf\" 2>/dev/null
        done
        local conf_file=\"\$CONFIG_DIR/\$(echo \$country | tr '[:upper:]' '[:lower:]' | cut -c1-2).conf\"
        if [ -f \"\$conf_file\" ]; then
            wg-quick up \"\$conf_file\"
        else
            echo \"ERROR: No config for \$country (\$conf_file)\"
            return 1
        fi
    fi
    sleep 3
    echo \"Public IP: \$(get_public_ip)\"
}

vpn_disconnect() {
    if [ \"\$VPN_METHOD\" = \"nordvpn\" ]; then
        nordvpn disconnect
    else
        for conf in \$CONFIG_DIR/*.conf; do
            wg-quick down \"\$conf\" 2>/dev/null
        done
    fi
}

vpn_rotate() {
    local current=\$(get_current)
    local next_idx=0
    for i in \"\${!COUNTRIES[@]}\"; do
        if echo \"\${COUNTRIES[\$i]}\" | grep -qi \"\$current\"; then
            next_idx=$(( (i + 1) % \${#COUNTRIES[@]} ))
            break
        fi
    done
    vpn_disconnect
    sleep 2
    vpn_connect \"\${COUNTRIES[\$next_idx]}\"
}

vpn_status() {
    echo \"Method:  \$VPN_METHOD\"
    echo \"Country: \$(get_current || echo 'disconnected')\"
    echo \"IP:      \$(get_public_ip || echo 'unknown')\"
}

case \"\${1:-status}\" in
    connect)    vpn_connect \"\$2\" ;;
    disconnect) vpn_disconnect ;;
    rotate)     vpn_rotate ;;
    status)     vpn_status ;;
    *)          echo \"Usage: \$0 {connect [country]|disconnect|rotate|status}\" ;;
esac
SCRIPT
chmod +x $VPN_CONFIG_DIR/vpn-rotate.sh"
```

### Test rotation

```bash
ssh $CT_HOST "$VPN_CONFIG_DIR/vpn-rotate.sh connect"
ssh $CT_HOST "$VPN_CONFIG_DIR/vpn-rotate.sh status"
ssh $CT_HOST "$VPN_CONFIG_DIR/vpn-rotate.sh rotate"
ssh $CT_HOST "$VPN_CONFIG_DIR/vpn-rotate.sh status"
ssh $CT_HOST "$VPN_CONFIG_DIR/vpn-rotate.sh disconnect"
```

Each `rotate` should switch countries and show a different IP.

---

## Verification Checklist

```bash
echo "=== VPN Setup Check ==="
echo ""
echo "TUN device:   $(ls /dev/net/tun 2>/dev/null && echo 'OK' || echo 'MISSING')"
echo "VPN method:   $(command -v nordvpn >/dev/null && echo 'NordVPN CLI' || echo 'WireGuard')"
echo "Configs:      $(ls $VPN_CONFIG_DIR/*.conf 2>/dev/null | wc -l) country configs"
echo "Rotation:     $(ls $VPN_CONFIG_DIR/vpn-rotate.sh 2>/dev/null && echo 'OK' || echo 'MISSING')"
echo ""
echo "Quick connect test..."
$VPN_CONFIG_DIR/vpn-rotate.sh connect
echo "VPN IP:       $(curl -s https://ifconfig.me)"
echo "Local access: $(curl -s -o /dev/null -w '%{http_code}' http://localhost:9000/ 2>/dev/null || echo 'N/A')"
$VPN_CONFIG_DIR/vpn-rotate.sh disconnect
echo "Home IP:      $(curl -s https://ifconfig.me)"
```

---

## Troubleshooting

### "RTNETLINK answers: Operation not permitted" on wg-quick up

TUN device not available. Go back to Step 1. Container may need restart after adding cgroup entries.

### VPN connects but local services unreachable

Split tunneling not configured. The VPN is routing ALL traffic including LAN. Fix the `AllowedIPs` or add PostUp routes per Step 3.

### DNS stops working when VPN is up

NordVPN CLI: `nordvpn set dns off` (use container's [[dns]], not NordVPN's).
WireGuard: Remove the `DNS =` line from the `.conf` file.

### "Cannot open TUN/TAP dev /dev/net/tun: No such file or directory"

Container config missing TUN mount entry. Check `/etc/pve/lxc/${CTID}.conf` for both the cgroup allow and mount entry lines.

### NordVPN CLI installed but nordvpnd won't start

Common in LXC. `systemctl status nordvpnd` will usually show a cgroup or namespace error. Fall through to WireGuard (Step 3).

---

*Last updated: 2026-02-13*
