Twelve builds

A hardened WireGuard server with IPv6 that actually routes

Build a WireGuard endpoint that hands clients a private v4 address and a genuinely routed IPv6 prefix, behind an nftables policy that drops everything unasked for.

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 link

That 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 --system

The 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.psk

The 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/128

On 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.conf

4. 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: yes

Unbound 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 -20

6. Start it

systemctl enable --now wg-quick@wg0
systemctl restart unbound
wg show wg0

7. 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 = 25

For a phone, turn that into a QR code on the server and photograph the terminal:

qrencode -t ansiutf8 < /root/laptop.conf

On 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 transfer

A 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 +short

The 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

SymptomCauseFix
Handshake succeeds, no traffic passesForwarding off, or the forward chain dropssysctl net.ipv4.ip_forward, then re-read the forward chain
Small requests fine, large downloads hangMTU and path discoveryDrop both configs to MTU = 1380 and retry
IPv6 dies about ten minutes after bootForwarding killed router advertisementsaccept_ra = 2 on the outward interface
DNS resolves outside the tunnelClient ignored DNS =Install openresolv on the client
Everything gone after a rebootUnits never enabledsystemctl 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.

Ready when you are

Pick a city. Pick a size. Pay in coin.

No forms about who you are, no wait for a human to approve you, no phone call to verify anything. The invoice clears and the credentials land in your inbox.