What this builds
One instance running WireGuard, handing every client a private IPv4 address, a routed IPv6 address out of your own /48, and a resolver that lives inside the tunnel so DNS queries never take a different path than the rest of the traffic. The firewall is default-drop in both directions. Forwarding is enabled only for the tunnel interface, and only outbound.
Tested on an R-4 in AMS-01 with Debian 13. WireGuard is single-flow and kernel-side, so more cores buy you nothing until you are pushing multiple gigabits through it. What matters far more is the site: pick the one closest to where you actually are, because every millisecond you add here you add to every packet.
Before you start
- A deployed instance and root over SSH.
- The IPv6 /48 add-on enabled in the panel. The included /64 works for a single client, but per-client subnets out of a single /64 means proxying neighbour discovery, and that is a hobby rather than a configuration.
- A hostname pointing at the instance. Everything below uses
vpn.example.com. - Every
2001:db8:address here is documentation space. Replace the prefix with the one shown on your instance page.
1. Packages and kernel settings
apt update && apt full-upgrade -y
apt install -y wireguard-tools nftables unbound qrencode
ip -br linkThat last command prints your outward-facing interface name. On our KVM images it is ens3; if yours differs, substitute it everywhere below. Now the kernel:
cat > /etc/sysctl.d/99-wireguard.conf <<EOF
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
net.ipv6.conf.ens3.accept_ra = 2
net.ipv4.conf.all.rp_filter = 1
EOF
sysctl --systemThe accept_ra = 2 line is the one people forget. Turning on IPv6 forwarding makes the kernel stop accepting router advertisements, the default route disappears within ten minutes, and the machine goes dark over v6 while still answering on v4. Setting it to two keeps accepting advertisements on a forwarding host.
2. Keys
install -d -m 700 /etc/wireguard
umask 077
wg genkey | tee /etc/wireguard/server.key | wg pubkey > /etc/wireguard/server.pub
wg genkey | tee /etc/wireguard/laptop.key | wg pubkey > /etc/wireguard/laptop.pub
wg genpsk > /etc/wireguard/laptop.pskThe pre-shared key is not optional in this build. It costs nothing, it is one line in each config, and it is the difference between a tunnel that is merely secure today and one that stays secure against an adversary recording traffic now to decrypt later.
3. The server interface
Write /etc/wireguard/wg0.conf, pasting in the key contents where marked:
[Interface]
Address = 10.7.0.1/24, 2001:db8:1a2b:7::1/64
ListenPort = 51820
PrivateKey = <contents of /etc/wireguard/server.key>
MTU = 1420
[Peer]
# laptop
PublicKey = <contents of /etc/wireguard/laptop.pub>
PresharedKey = <contents of /etc/wireguard/laptop.psk>
AllowedIPs = 10.7.0.2/32, 2001:db8:1a2b:7::2/128On the server side AllowedIPs is a routing table, not a permission list. Give each peer exactly the addresses it owns and nothing wider, or two clients will fight over the same route and the loser will simply stop receiving packets.
chmod 600 /etc/wireguard/wg0.conf4. A resolver inside the tunnel
Pointing clients at a public resolver undoes a good deal of the point. Unbound is already installed; give it /etc/unbound/unbound.conf.d/tunnel.conf:
server:
interface: 10.7.0.1
interface: 2001:db8:1a2b:7::1
access-control: 10.7.0.0/24 allow
access-control: 2001:db8:1a2b:7::/64 allow
do-ip6: yes
prefetch: yes
hide-identity: yes
hide-version: yes
qname-minimisation: yesUnbound will fail to start before the tunnel exists, because those addresses are not up yet. Sequence it properly rather than fighting it:
systemctl edit unbound[Unit]
[email protected]
[email protected]5. The firewall
Replace /etc/nftables.conf entirely:
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
ct state established,related accept
ct state invalid drop
iif lo accept
ip protocol icmp accept
ip6 nexthdr icmpv6 accept
tcp dport 22 accept
udp dport 51820 accept
iifname "wg0" udp dport 53 accept
iifname "wg0" tcp dport 53 accept
}
chain forward {
type filter hook forward priority filter; policy drop;
ct state established,related accept
iifname "wg0" oifname "ens3" accept
iifname "wg0" oifname "wg0" drop
}
chain output {
type filter hook output priority filter; policy accept;
}
}
table ip nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 10.7.0.0/24 oifname "ens3" masquerade
}
}Note what is absent: there is no NAT table for IPv6. Clients egress with their own address out of your /48, which is the entire reason for taking the /48 in the first place. The iifname "wg0" oifname "wg0" drop line stops peers reaching each other, which you want unless you are deliberately building a mesh.
systemctl enable --now nftables
nft list ruleset | head -206. Start it
systemctl enable --now wg-quick@wg0
systemctl restart unbound
wg show wg07. The client
[Interface]
PrivateKey = <contents of /etc/wireguard/laptop.key>
Address = 10.7.0.2/24, 2001:db8:1a2b:7::2/64
DNS = 10.7.0.1
MTU = 1420
[Peer]
PublicKey = <contents of /etc/wireguard/server.pub>
PresharedKey = <contents of /etc/wireguard/laptop.psk>
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 25For a phone, turn that into a QR code on the server and photograph the terminal:
qrencode -t ansiutf8 < /root/laptop.confOn a Debian or Ubuntu client the DNS = line needs openresolv installed, otherwise wg-quick prints a warning and silently leaves your old resolver in place. That is the most common cause of a tunnel that works perfectly and leaks every lookup.
Verify it
Four checks, in order. Anything that fails tells you which step to go back to.
# on the server, after connecting the client
wg show wg0 latest-handshakes
wg show wg0 transferA handshake timestamp within the last two minutes and non-zero counters in both directions mean the cryptography and the routing are both fine.
# on the client
ping -c3 10.7.0.1
ping -c3 2001:db8:1a2b:7::1
dig @10.7.0.1 paragonvps.com AAAA +shortThe third command proves the tunnel resolver is answering. If it times out, unbound started before wg0 existed; restart it.
The last check is the only one that proves your traffic leaves where you think it does, and it needs a second machine. Any other instance in the fleet will do, in any city:
# on the second instance
nc -lv 9101
# on the client, tunnel up
curl -m5 -4 http://second.example.com:9101/
curl -m5 -6 http://second.example.com:9101/The listener prints the source address of each connection. Your v4 attempt should arrive from the WireGuard server, because it was masqueraded. The v6 attempt should arrive from the client’s own tunnel address inside your /48, untouched, which is what routed means and proxied does not.
When it goes wrong
| Symptom | Cause | Fix |
|---|---|---|
| Handshake succeeds, no traffic passes | Forwarding off, or the forward chain drops | sysctl net.ipv4.ip_forward, then re-read the forward chain |
| Small requests fine, large downloads hang | MTU and path discovery | Drop both configs to MTU = 1380 and retry |
| IPv6 dies about ten minutes after boot | Forwarding killed router advertisements | accept_ra = 2 on the outward interface |
| DNS resolves outside the tunnel | Client ignored DNS = | Install openresolv on the client |
| Everything gone after a reboot | Units never enabled | systemctl enable wg-quick@wg0 nftables unbound |
From here, the sensible next steps are a second endpoint in another region and a monitoring stack that tells you when one of them stops answering. Both are covered elsewhere in the guides; the network page explains what the edge does with your traffic before it reaches you.