WireGuard VPN Server on OpenWrt

Guide for OpenWrt 25.12 (GL.iNet Flint 2 / GL-MT6000, vanilla OpenWrt)

Remote access to the homelab (192.168.1.0/24) without exposing any service to the internet. The only thing reachable from outside is a single UDP port that responds exclusively to valid WireGuard handshakes. Uses DuckDNS to track the dynamic WAN IP.

Prerequisites:

  • Vanilla OpenWrt 25.12+ (apk package manager)
  • A public (non-CGNAT) WAN IP
  • SSH access to the router as root

Step 1: Verify You Have a Public IP

From any LAN machine:

curl -s ifconfig.me

On the router:

ifstatus wan | grep '"address"'

What this does:

  • curl -s ifconfig.me – Asks an external service what IP your traffic appears from
  • ifstatus wan – Shows the address actually assigned to the router’s WAN interface

Result: If both IPs match, you have a public IP and can run a VPN server. If the WAN address is in 100.64.0.0/10 or a private range while curl shows something different, you are behind CGNAT and inbound WireGuard will not work (use Tailscale instead).

Step 2: Create a DuckDNS Hostname

  1. Sign in at duckdns.org
  2. Add a subdomain (e.g. example.duckdns.org)
  3. Note the account token shown at the top of the page

Security note: The token is the only credential protecting the record. Treat it as a secret; it can be regenerated via the account icon → “regen token”.

Step 3: Install the DDNS Client on the Router

apk update
apk add luci-app-ddns ca-certificates

What this does:

  • luci-app-ddns – LuCI page for DDNS; pulls in ddns-scripts and ddns-scripts-services (which contains the duckdns.org provider definition) as dependencies
  • ca-certificates – CA bundle so the update runs over verified HTTPS

Step 4: Configure DDNS via UCI

Replace example with your subdomain and YOUR_TOKEN with your DuckDNS token:

uci set ddns.duckdns=service
uci set ddns.duckdns.enabled='1'
uci set ddns.duckdns.service_name='duckdns.org'
uci set ddns.duckdns.lookup_host='example.duckdns.org'
uci set ddns.duckdns.domain='example'
uci set ddns.duckdns.password='YOUR_TOKEN'
uci set ddns.duckdns.use_ipv6='0'
uci set ddns.duckdns.ip_source='network'
uci set ddns.duckdns.ip_network='wan'
uci set ddns.duckdns.use_https='1'
uci set ddns.duckdns.cacert='/etc/ssl/certs'
uci set ddns.duckdns.check_interval='10'
uci set ddns.duckdns.check_unit='minutes'
uci set ddns.duckdns.force_interval='72'
uci set ddns.duckdns.force_unit='hours'
uci commit ddns

/etc/init.d/ddns enable
/etc/init.d/ddns restart

What this does:

  • service_name='duckdns.org' – Selects the provider definition from the services file
  • domain='example' – DuckDNS expects the bare subdomain, not the full hostname
  • password='YOUR_TOKEN' – The DuckDNS token goes in the password field
  • lookup_host – Hostname used to check what DNS currently answers
  • ip_source='network' / ip_network='wan' – Reads the IP directly from the WAN interface instead of an external detection service
  • check_interval 10 minutes – How often the IP is compared against DNS
  • force_interval 72 hours – Push an update even when nothing changed, so the record never goes stale

Verify:

cat /var/log/ddns/duckdns.log

Look for DDNS Provider answered: OK near the end.

Step 5: Install WireGuard

apk add luci-proto-wireguard kmod-wireguard wireguard-tools qrencode

What this does:

  • kmod-wireguard – The kernel module that does the actual encryption
  • wireguard-tools – The wg and wg genkey userspace utilities
  • luci-proto-wireguard – Lets LuCI and netifd manage WireGuard interfaces
  • qrencode – Renders client configs as QR codes for the mobile app (terminal output only on OpenWrt; PNG output is compiled out)

Verify:

modinfo wireguard | head -3
wg --version

Step 6: Generate the Server Keypair

mkdir -p /etc/wireguard
chmod 700 /etc/wireguard
umask 077
wg genkey | tee /etc/wireguard/server.key | wg pubkey > /etc/wireguard/server.pub

What this does:

  • umask 077 – Ensures every file created is readable by root only (mode 600)
  • wg genkey – Generates the private key
  • tee – Writes the private key to server.key while passing it on to
  • wg pubkey – Derives the matching public key into server.pub

Result: server.key stays on the router and is never shared. server.pub goes into every client config.

Step 7: Create the WireGuard Interface and Firewall Rules

VPN subnet used here: 10.10.10.0/24 (router = 10.10.10.1). Deliberately outside 192.168.x.x so it never collides with the LAN or with whatever network a client is physically on (hotel/guest WiFi is very often 192.168.0.x or 192.168.1.x). Listen port 48222 is arbitrary; a nonstandard port just reduces scanner noise in logs.

First confirm the first firewall zone is lan (the commands below reference it positionally):

uci show firewall.@zone[0].name

Then:

uci set network.wg0=interface
uci set network.wg0.proto='wireguard'
uci set network.wg0.private_key="$(cat /etc/wireguard/server.key)"
uci set network.wg0.listen_port='48222'
uci add_list network.wg0.addresses='10.10.10.1/24'

uci add firewall rule
uci set firewall.@rule[-1].name='Allow-WireGuard'
uci set firewall.@rule[-1].src='wan'
uci set firewall.@rule[-1].dest_port='48222'
uci set firewall.@rule[-1].proto='udp'
uci set firewall.@rule[-1].target='ACCEPT'

uci add_list firewall.@zone[0].network='wg0'

uci commit network
uci commit firewall

/etc/init.d/network restart
/etc/init.d/firewall restart

What this does:

  • network.wg0 – Defines the WireGuard interface with the server key, port, and tunnel address
  • Allow-WireGuard rule – Opens UDP 48222 from WAN for the handshake
  • add_list firewall.@zone[0].network='wg0' – Puts wg0 in the lan zone, so VPN clients are treated like LAN devices and can reach 192.168.1.x. Without this, clients handshake successfully but all their traffic is dropped

Note: /etc/init.d/network restart may print Command failed: Not found. This can be cosmetic; verify actual state instead of retrying blindly.

Verify:

wg show
ip addr show wg0

Expect wg0 listening on 48222 with address 10.10.10.1/24. If wg0 is missing, run ifup wg0 and check again.

Step 8: Add a Client (Peer)

Each device gets its own keypair and the next free /32 (first client 10.10.10.2, second 10.10.10.3, and so on). Example for a phone:

umask 077
wg genkey | tee /etc/wireguard/phone.key | wg pubkey > /etc/wireguard/phone.pub

uci add network wireguard_wg0
uci set network.@wireguard_wg0[-1].description='phone'
uci set network.@wireguard_wg0[-1].public_key="$(cat /etc/wireguard/phone.pub)"
uci add_list network.@wireguard_wg0[-1].allowed_ips='10.10.10.2/32'
uci commit network
ifup wg0

What this does:

  • wireguard_wg0 section – Registers a peer on the wg0 interface
  • allowed_ips='10.10.10.2/32' – Server-side, this means “packets from this peer must come from this address, and traffic to this address goes to this peer”. One address per device
  • ifup wg0 – Reloads the interface so the new peer is active

Verify:

wg show

The peer should be listed with its allowed IPs.

Step 9: Generate the Client Configuration

cat > /etc/wireguard/phone.conf << EOF
[Interface]
PrivateKey = $(cat /etc/wireguard/phone.key)
Address = 10.10.10.2/32
DNS = 192.168.1.1

[Peer]
PublicKey = $(cat /etc/wireguard/server.pub)
Endpoint = example.duckdns.org:48222
AllowedIPs = 192.168.1.0/24, 10.10.10.0/24
PersistentKeepalive = 25
EOF

What this does:

  • DNS = 192.168.1.1 – Clients resolve via the router (keeps LAN ad-blocking active over VPN)
  • Endpoint – The DuckDNS hostname plus the listen port
  • AllowedIPs (client-side) – The routes sent through the tunnel. As written this is a split tunnel: only homelab traffic uses the VPN; everything else uses the client’s normal connection. Use 0.0.0.0/0 instead for a full tunnel (all traffic routed home; useful on untrusted WiFi, costs battery and home upload bandwidth)
  • PersistentKeepalive = 25 – Keeps NAT mappings alive so the tunnel stays reachable from behind mobile carrier NAT

Step 10: Provision a Phone

Render the config as a QR code and scan it with the official WireGuard app (+ → Scan from QR code):

qrencode -t utf8 < /etc/wireguard/phone.conf

If the terminal QR will not scan (dark themes and line spacing distort it), generate a PNG on a Linux box instead. OpenWrt’s qrencode cannot output PNG, and the router’s dropbear has no SFTP server, so modern scp needs the -O flag:

scp -O root@192.168.1.1:/etc/wireguard/phone.conf /tmp/phone.conf
qrencode -t png -o /tmp/phone-wg.png < /tmp/phone.conf
rm /tmp/phone.conf

Open the PNG full screen, scan it, then delete it (it encodes the private key):

rm /tmp/phone-wg.png

Step 11: Provision a Linux Client

Copy the config while still on the LAN, then:

sudo apt update && sudo apt install wireguard
sudo mv /tmp/mint.conf /etc/wireguard/wg0.conf
sudo chmod 600 /etc/wireguard/wg0.conf

Bring the tunnel up and down manually:

sudo wg-quick up wg0
sudo wg-quick down wg0

Alternative: import into NetworkManager for a tray toggle (nmcli connection import type wireguard file /etc/wireguard/wg0.conf). Use one mechanism or the other, not both, or they will conflict over the interface.

Step 12: Test From Outside the LAN

With the client on mobile data or a hotspot (not home WiFi):

sudo wg show

What to look for:

  • latest handshake a few seconds old – The tunnel is established
  • transfer showing received bytes climbing – Traffic is flowing both ways (sent climbing while received stays 0 means the handshake is failing)

Then open a LAN service in the browser, e.g. http://192.168.1.60:8096 for Jellyfin. Services are reached by their normal LAN addresses; the 10.10.10.x addresses only identify tunnel endpoints.

Step 13: Cleanup and Backup

Client private keys are only needed on the client. Once provisioned, remove them from the router (peers in UCI store only public keys):

rm /etc/wireguard/phone.key /etc/wireguard/phone.conf

Take a config backup and pull it off the router (/tmp is RAM-backed and vanishes on reboot):

sysupgrade -b /tmp/backup-$(date +%Y%m%d).tar.gz

From a LAN or VPN-connected machine:

scp -O root@192.168.1.1:/tmp/backup-20260719.tar.gz ~/

Security Notes

  • Nothing on the LAN is exposed to the internet. The only open port is UDP 48222, and it responds only to valid handshakes from registered peer keys; to a port scanner it looks closed
  • The backup archive contains the server private key and the DuckDNS token. Store it as a secret, never in a git repository
  • The DuckDNS token ends up in plaintext in /etc/config/ddns (root-readable only). This is standard for ddns-scripts. Note that a hijacked DDNS hostname cannot impersonate the server, since WireGuard clients authenticate the server by its public key; the worst case is denial of service
  • If a client device is lost or wiped, remove its peer section from UCI (or regenerate the keypair and update the peer’s public key). The remaining peers are unaffected
  • Avoid restarting the network or firewall services while managing the router over the VPN; you will cut your own session and need LAN access to recover