A “road-warrior” VPN (Virtual Private Network) is the laptop-and-phone case: individual client devices that connect back to a home or office network from wherever they happen to be: a hotel, a coffee shop, a client site. Each client gets an address inside your network and, in full-tunnel mode, routes all its traffic through home. You get a trusted exit point on untrusted networks, plus reachability to your internal services.
This tutorial builds that on OpenWrt with WireGuard. By the end you will have a VPN server in its own firewall zone, IPv4 and IPv6 for clients, a port that punches through restrictive networks, and a repeatable process for adding peers.
The addresses below are illustrative and follow the same example plan used in the mesh posts: LAN on 10.0.10.0/24, road-warrior VPN on 10.0.60.0/24, and ULA IPv6 under fd00:1234:5678::/48. Substitute ranges that do not overlap anything already in your network.
If you want the why behind WireGuard’s cryptographic choices, I wrote about that separately. This is the operational how-to.
Decide the design first
Four decisions shape everything below. Make them now:
- Dedicated subnet and firewall zone. The VPN gets its own subnet (I will use
10.0.60.0/24) and its own firewall zone, not a slot on your trusted LAN. Remote access is a distinct trust tier, and it should be isolated as one. This is the same default-deny zone discipline I argue for in Network Segmentation Is Not Zero Trust.- Full tunnel vs. split tunnel. Full tunnel (
AllowedIPs = 0.0.0.0/0, ::/0) routes everything through home, best for using your network as a trusted exit on hostile Wi-Fi. Split tunnel routes only your internal ranges. This tutorial does full tunnel; the difference is one line in the client config.- IPv4 and IPv6. Clients get both. IPv6 needs a little extra handling (covered below), but leaving it off means clients silently fall back to IPv4 in dual-stack situations and you lose the trusted-exit guarantee for v6 traffic.
- A stable endpoint. Clients need a name or address to dial. If your WAN address is dynamic, use dynamic DNS so the endpoint name follows it.
Step 1: Generate the server keypair
On the router:
wg genkey | tee /tmp/wg_server_priv | wg pubkey > /tmp/wg_server_pub
cat /tmp/wg_server_pub # note this; clients need itKeep the private key on the router. It will go into the config and then the temp file should be removed.
Step 2: Create the WireGuard interface
Define the server interface in UCI. Pick a listen port (I will use 51820, the WireGuard default) and give the interface its IPv4 and IPv6 (ULA, Unique Local Address) addresses.
uci set network.wg0=interface
uci set network.wg0.proto='wireguard'
uci set network.wg0.private_key="$(cat /tmp/wg_server_priv)"
uci set network.wg0.listen_port='51820'
uci add_list network.wg0.addresses='10.0.60.1/24'
uci add_list network.wg0.addresses='fd00:1234:5678:60::1/64' # your ULA, /64 for the VPN
uci commit network
/etc/init.d/network reloadNote
Use a ULA prefix you generated for your own network (anything in
fd00::/8), not the example above. The VPN’s/64should be carved from the same stable ULA you use elsewhere internally; ULAs do not change when your ISP rotates your delegated prefix, so VPN addressing stays put.
Step 3: Put the VPN in its own firewall zone
Create a vpn zone, bind the interface to it, and set the inter-zone policy. The intent: VPN clients reach the internet and the LAN, but the VPN zone is isolated inbound and peers are walled off from each other.
# A dedicated zone for the VPN interface
uci add firewall zone
uci set firewall.@zone[-1].name='vpn'
uci set firewall.@zone[-1].input='ACCEPT'
uci set firewall.@zone[-1].output='ACCEPT'
uci set firewall.@zone[-1].forward='REJECT' # peers cannot reach each other
uci add_list firewall.@zone[-1].network='wg0'
# vpn -> wan: clients route to the internet (full tunnel exit)
uci add firewall forwarding
uci set firewall.@forwarding[-1].src='vpn'
uci set firewall.@forwarding[-1].dest='wan'
# vpn -> lan: clients reach internal services
uci add firewall forwarding
uci set firewall.@forwarding[-1].src='vpn'
uci set firewall.@forwarding[-1].dest='lan'
uci commit firewall
/etc/init.d/firewall reloadforward='REJECT' inside the zone is the important line: it stops one connected client from pivoting to another. Each peer can reach the network, not the rest of the access tier. (NAT (Network Address Translation) masquerade on the wan zone, already present in a standard OpenWrt setup, handles the IPv4 exit; IPv6 is covered below.)
Step 4: Make the endpoint survive hostile networks
WireGuard is UDP (User Datagram Protocol). Many restrictive networks (hotels, conference Wi-Fi, locked-down corporate guest networks) block arbitrary outbound UDP but allow UDP to ports associated with normal services. Two ports are almost always open: 443 (HTTPS over QUIC) and 53 (DNS).
So rather than ask clients to reach 51820 directly, forward those friendly ports to it with DNAT (Destination NAT). Clients dial 443; the router redirects to the real listen port.
# Primary: UDP/443 -> WireGuard
uci add firewall redirect
uci set firewall.@redirect[-1].name='wg-443'
uci set firewall.@redirect[-1].src='wan'
uci set firewall.@redirect[-1].proto='udp'
uci set firewall.@redirect[-1].src_dport='443'
uci set firewall.@redirect[-1].dest='vpn'
uci set firewall.@redirect[-1].dest_ip='10.0.60.1'
uci set firewall.@redirect[-1].dest_port='51820'
# Fallback: UDP/53 -> WireGuard
uci add firewall redirect
uci set firewall.@redirect[-1].name='wg-53'
uci set firewall.@redirect[-1].src='wan'
uci set firewall.@redirect[-1].proto='udp'
uci set firewall.@redirect[-1].src_dport='53'
uci set firewall.@redirect[-1].dest='vpn'
uci set firewall.@redirect[-1].dest_ip='10.0.60.1'
uci set firewall.@redirect[-1].dest_port='51820'
uci commit firewall
/etc/init.d/firewall reloadTip
Configure each client with the primary port (
:443) and keep the fallback (:53) noted for when you land on a network that blocks even 443/UDP. The actual listen port stays internal; externally, the VPN looks like traffic to ordinary service ports, which is exactly why it gets through.
Warning
Reusing UDP/443 and UDP/53 is a tradeoff. UDP/443 can conflict with HTTP/3 / QUIC on the same WAN address, and UDP/53 can conflict with a public DNS service. That is fine if you are not serving those protocols directly from the router. For example, if DNS on the network is only a local recursive or forwarding resolver that reaches upstream resolvers over DNS-over-TLS, then taking UDP/53 on the WAN for WireGuard does not steal a service you were publishing.
Step 5: A stable endpoint with dynamic DNS
If your WAN address is static, skip this; clients just use the address. If it is dynamic, install ddns-scripts (and the LuCI app if you like), point a hostname at your WAN address, and use that hostname as the client endpoint.
apk update
apk add ddns-scripts luci-app-ddnsConfigure it against your DNS provider so vpn.example.com always resolves to your current WAN address. Clients then dial vpn.example.com:443 and reconnect cleanly across address changes.
Step 6: Add a peer (server side)
For each client, generate a keypair, assign the next free address, and register the peer’s public key on the server.
# Generate the client's keypair on the router
wg genkey | tee /tmp/peer_priv | wg pubkey > /tmp/peer_pub
# Register the peer on wg0: allowed_ips is the address(es) THIS peer may use
uci add network wireguard_wg0
uci set network.@wireguard_wg0[-1].description='laptop'
uci set network.@wireguard_wg0[-1].public_key="$(cat /tmp/peer_pub)"
uci add_list network.@wireguard_wg0[-1].allowed_ips='10.0.60.2/32'
uci add_list network.@wireguard_wg0[-1].allowed_ips='fd00:1234:5678:60::2/128'
uci commit network
/etc/init.d/network reloadWarning
On the server,
allowed_ipsis a routing/identity filter: it is the exact set of source addresses this peer is permitted to use, so keep it tight (/32and/128, one host each). Do not confuse it with the client’sAllowedIPs, which in full-tunnel mode is0.0.0.0/0, ::/0. Same keyword, opposite jobs.
Assign addresses by a simple convention (next free host: .2, .3, …) and keep a list somewhere; the router does not name peers for you beyond the description.
Step 7: The client configuration
Hand the client these values. The private key comes from /tmp/peer_priv, the server public key from Step 1, the endpoint from Step 5.
[Interface]
PrivateKey = <contents of /tmp/peer_priv>
Address = 10.0.60.2/32, fd00:1234:5678:60::2/128
DNS = 10.0.60.1, fd00:1234:5678:60::1
[Peer]
PublicKey = <server public key from Step 1>
Endpoint = vpn.example.com:443
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25
AllowedIPs = 0.0.0.0/0, ::/0is what makes it full-tunnel: all traffic goes home. For split tunnel, list only your internal ranges instead.DNSpoints at the server so name resolution also goes through the tunnel (no leaking queries to the local café’s resolver). Point it at whatever internal resolver you run.PersistentKeepalive = 25keeps the session alive through NAT on the client’s side, so the connection survives idle periods behind a hostile router.
Deliver the config over a secure channel, then remove the temporary keys from the router:
rm /tmp/peer_priv /tmp/peer_pubWarning
The client’s private key only needs to exist long enough to reach the client. Do not email it in plaintext, do not leave it in
/tmp, and never put any private key into documentation or version control.
IPv6: outbound for clients
Clients have a ULA, but a ULA is not internet-routable. To give full-tunnel clients working IPv6 to the internet, masquerade their traffic out the WAN with NAT66, the IPv6 analogue of IPv4 masquerade.
# Masquerade VPN client IPv6 out the WAN (NAT66)
uci add firewall nat
uci set firewall.@nat[-1].name='vpn-v6-masq'
uci set firewall.@nat[-1].family='ipv6'
uci set firewall.@nat[-1].src='wan'
uci set firewall.@nat[-1].src_ip='fd00:1234:5678:60::/64'
uci set firewall.@nat[-1].target='MASQUERADE'
uci commit firewall
/etc/init.d/firewall reloadNote
NAT66 is the pragmatic choice here precisely because the clients use a stable ULA. The cleaner alternative (handing clients addresses from your ISP-delegated global prefix) breaks every time that prefix rotates. Masquerading a never-changing ULA out the WAN trades IPv6 purity for a VPN that does not fall over when your ISP re-delegates.
Verify
On the router, check the tunnel and each peer:
wg show wg0Connect a client, then look for that peer’s latest handshake with a recent timestamp and transfer counting up in both directions. A peer with no handshake is not connected; a stale one has dropped. From the client, confirm the exit is actually home:
curl -4 https://ifconfig.co # should show your home WAN IPv4
curl -6 https://ifconfig.co # should show a home-side IPv6If both report your home address, the full tunnel is live for v4 and v6.
Security notes
- Keys live on the router, managed through UCI. The server private key and each peer’s public key are in
/etc/config/network. Back that up securely; treat it as sensitive. - Peers are isolated from each other by the
forward='REJECT'in the VPN zone (Step 3). Connecting to the network does not grant lateral reach within the access tier. - One front door. The only inbound exception on the WAN is the DNAT to WireGuard. Everything else stays dropped.
- Rotating a peer is just generating a new keypair, replacing that peer’s
public_key, reloading, and delivering the new private key. Revoking one is deleting itswireguard_wg0section.
The takeaway
A road-warrior WireGuard server is four moving parts: an interface with keys and addresses, a dedicated isolated firewall zone, a NAT/DNAT path that gets clients in (and IPv6 back out), and a per-peer registration step. Put the VPN in its own trust tier rather than on the LAN, listen behind 443/53 so the tunnel survives hostile networks, and use a stable ULA so IPv6 does not break when your ISP reshuffles your prefix. Once the first peer handshakes, every additional one is the same three commands.