WireGuard VPN on Debian 13 with nftables

13 min read

Most WireGuard tutorials end when ping works. They leave behind a forward chain that accepts everything, a NAT rule that masquerades any source, and nothing that tells you whether IPv6 is going around the tunnel. This guide builds a road-warrior WireGuard VPN server on Debian 13 that is locked down from the start. It uses a default-drop nftables policy that coexists with Debian's nftables.service, a PresharedKey on each peer, a split-tunnel client, and a check after every step.

What we build and prerequisites

A single Debian 13 (trixie) server with a public interface eth0, UDP port 51820 open, and one laptop client. The tunnel uses 10.8.0.0/24 and the ULA prefix fd42:42:42::/64. The client only sends tunnel-subnet traffic through the VPN (split tunnel). A full-tunnel variant comes later.

ComponentDebian 13 versionNotes
wireguard-tools1.0.20210914-3wg, wg-quick, wg-quick@.service
nftables1.1.3-1Loads /etc/nftables.conf
Kernel (linux-image-amd64)6.12.111-1 (trixie-security)WireGuard module is in-tree
systemd257.13-1~deb13u1Only needed for the networkd variant

Interface names differ between machines. Check yours with ip -br link and replace eth0 everywhere below. Keep a second SSH session or a provider console open for the whole setup.

Step-by-step WireGuard VPN server setup on Debian 13

1. Install packages

apt update
apt install wireguard-tools nftables tcpdump
modinfo wireguard | head -n 3

If modinfo prints a filename under /lib/modules/6.12…, the kernel side is ready. You don't need DKMS.

2. Enable forwarding (the sysctl pitfall)

Many guides, including the Debian wiki, still tell you to edit /etc/sysctl.conf. In Debian 13, systemd-sysctl no longer reads that file. The setting works when you run sysctl -p by hand, but it is gone after a reboot and your clients lose internet access. Use a drop-in in /etc/sysctl.d/ instead:

net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
# only if eth0 gets its IPv6 address via SLAAC/router advertisements:
net.ipv6.conf.eth0.accept_ra = 2

The accept_ra = 2 line matters. Once forwarding is on, the kernel ignores router advertisements unless accept_ra is 2. A server that gets IPv6 through SLAAC then quietly loses its default IPv6 route when it expires. ip_forward is listed first because changing it resets other per-interface parameters to their defaults.

sysctl --system
sysctl net.ipv4.ip_forward net.ipv6.conf.all.forwarding
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1

3. Generate keys with a strict umask

Set umask 077 before any key touches the disk, so files are created mode 0600 and never world-readable, even briefly. Generate the server key pair and one PresharedKey per client. The PSK adds a symmetric layer on top of the Curve25519 handshake. WireGuard documents it as a hedge against future quantum attacks.

umask 077
mkdir -p /etc/wireguard/keys
cd /etc/wireguard/keys
wg genkey | tee server.key | wg pubkey > server.pub
wg genpsk > client1.psk
ls -l
-rw------- 1 root root 45 Sep 30 10:02 client1.psk
-rw------- 1 root root 45 Sep 30 10:02 server.key
-rw------- 1 root root 45 Sep 30 10:02 server.pub

Generate the client's key on the client, so its private key never leaves the laptop. Only the public key comes back to the server:

umask 077
wg genkey | tee /etc/wireguard/client1.key | wg pubkey

Save that output on the server as /etc/wireguard/keys/client1.pub. Copy client1.psk to the client over SSH (scp), never by email or chat.

4. Server interface with wg-quick@

The private key stays in its own file and is loaded by PostUp, so you can share or diff the config without leaking it. Don't set SaveConfig. This file should stay the only source of truth.

[Interface]
Address = 10.8.0.1/24, fd42:42:42::1/64
ListenPort = 51820
PostUp = wg set %i private-key /etc/wireguard/keys/server.key

[Peer]
# client1 - laptop
PublicKey = <contents of client1.pub>
PresharedKey = <contents of client1.psk>
AllowedIPs = 10.8.0.2/32, fd42:42:42::2/128

The server-side AllowedIPs must be the client's own /32 and /128. That is WireGuard's source-address filter: packets from client1 with any other source address are dropped.

chmod 600 /etc/wireguard/wg0.conf
systemctl enable --now wg-quick@wg0
wg show wg0
ip -br addr show wg0
ss -ulpn 'sport = :51820'
interface: wg0
  public key: Hx3...=
  private key: (hidden)
  listening port: 51820

peer: Qm9...=
  preshared key: (hidden)
  allowed ips: 10.8.0.2/32, fd42:42:42::2/128

No latest handshake line yet, which is expected.

5. Default-drop nftables that coexists with other tables

Debian's stock /etc/nftables.conf starts with flush ruleset. Every time nftables.service reloads, it wipes all tables on the host, including the ones fail2ban or Docker created at runtime. The file below uses destroy table to replace only its own tables, which works even when they don't exist yet. If you run fail2ban with the nftables backend, its bans survive a reload.

Warning: this replaces /etc/nftables.conf and sets policy drop on input. If your SSH port is not 22, change it first, or you will lock yourself out. The same applies if you moved SSH while hardening SSH on Debian 13. A forward policy of drop also breaks Docker container traffic unless you allow it explicitly, so a dedicated VPN host is simpler.

#!/usr/sbin/nft -f
# Replace only our own tables; leave fail2ban/Docker tables alone
destroy table inet filter
destroy table inet wg_nat

define WAN = "eth0"
define WG  = "wg0"
define WG4 = 10.8.0.0/24
define WG6 = fd42:42:42::/64

table inet filter {
    chain input {
        type filter hook input priority filter; policy drop;
        ct state invalid drop
        ct state established,related accept
        iif "lo" accept
        meta l4proto { icmp, ipv6-icmp } accept
        tcp dport 22 accept
        iifname $WAN udp dport 51820 accept
        counter comment "input dropped"
    }
    chain forward {
        type filter hook forward priority filter; policy drop;
        tcp flags syn tcp option maxseg size set rt mtu
        ct state invalid drop
        ct state established,related accept
        iifname $WG oifname $WAN ip saddr $WG4 accept
        iifname $WG oifname $WAN ip6 saddr $WG6 accept
        counter comment "forward dropped"
    }
    chain output {
        type filter hook output priority filter; policy accept;
    }
}

table inet wg_nat {
    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        oifname $WAN ip saddr $WG4 masquerade
        oifname $WAN ip6 saddr $WG6 masquerade
    }
}

Clients can reach the internet, and replies come back through conntrack. Client-to-client traffic and anything from the internet to the tunnel subnet hits the drop policy. The masquerade rules only apply to tunnel sources leaving through eth0. The MSS clamp rewrites TCP SYNs to fit the route MTU, which heads off most MTU trouble in forwarded traffic.

Apply it with a dead-man switch. The rollback file restores the current ruleset after five minutes unless you cancel it:

{ echo 'flush ruleset'; nft list ruleset; } > /root/nft-rollback.nft
nft -c -f /etc/nftables.conf
systemd-run --on-active=5min --unit=nft-rollback /usr/sbin/nft -f /root/nft-rollback.nft
nft -f /etc/nftables.conf

Now open a new SSH session. Don't reuse the existing one, because established connections are always accepted. If the login works, cancel the rollback and enable the service:

systemctl stop nft-rollback.timer
systemctl enable nftables.service
nft list chain inet filter input

6. Split-tunnel client

[Interface]
PrivateKey = <contents of /etc/wireguard/client1.key>
Address = 10.8.0.2/32, fd42:42:42::2/128

[Peer]
PublicKey = <contents of server.pub>
PresharedKey = <contents of client1.psk>
Endpoint = vpn.example.com:51820
AllowedIPs = 10.8.0.0/24, fd42:42:42::/64
PersistentKeepalive = 25

On the client, AllowedIPs works as the routing table: only these prefixes go into the tunnel. PersistentKeepalive keeps NAT mappings open when the laptop sits behind a home router or on mobile data.

chmod 600 /etc/wireguard/wg0.conf
wg-quick up wg0

How to verify the tunnel works

Handshake

ping -c 3 10.8.0.1
wg show wg0 latest-handshakes
wg show wg0 transfer

latest-handshakes prints a Unix timestamp per peer. A value of 0 means no handshake has ever completed.

Encryption on the wire (tcpdump)

On the server, watch both sides of the tunnel at once:

tcpdump -ni eth0 udp port 51820
tcpdump -ni wg0 icmp

On eth0 you should only see UDP datagrams to and from the client's public IP. The decrypted ICMP echo requests appear on wg0 alone.

Split-tunnel leak test

Ask the kernel on the client which way each destination goes. This is deterministic and doesn't depend on a third-party "what is my IP" site:

ip route get 10.8.0.1
ip route get 9.9.9.9
ip -6 route get fd42:42:42::1
ip -6 route get 2620:fe::fe
10.8.0.1 dev wg0 src 10.8.0.2 uid 0
9.9.9.9 via 192.168.1.1 dev wlan0 src 192.168.1.23 uid 0
fd42:42:42::1 from :: dev wg0 src fd42:42:42::2 metric 256 pref medium
2620:fe::fe from :: via fe80::1 dev wlan0 proto ra src 2001:db8::23 metric 600 pref medium

In split mode, tunnel prefixes go through wg0 and everything else, including DNS, uses the local network on purpose. To confirm nothing leaks in the clear, run tcpdump -ni wlan0 host 10.8.0.1 on the client while pinging. It must stay silent.

Full tunnel, MTU and the IPv6 leak

Split tunnelFull tunnel
Client AllowedIPs10.8.0.0/24, fd42:42:42::/640.0.0.0/0, ::/0
DNSLocal resolverDNS = line, needs openresolv or systemd-resolved
Routing by wg-quickPlain routesTable 51820 with fwmark and a suppress_prefixlength 0 rule
Leak riskBy designIPv6 if ::/0 is missing

IPv6 leak. The classic mistake is AllowedIPs = 0.0.0.0/0 on its own. IPv4 goes through the tunnel, but a dual-stack laptop still sends IPv6 straight out of its Wi-Fi, so your real address shows up to any v6-capable site. Always include ::/0, even if the server has no IPv6 uplink. In that case v6 packets die at the server's forward policy and applications fall back to IPv4 rather than leaking. For the full-tunnel leak test, ip -6 route get 2620:fe::fe must report dev wg0 table 51820.

MTU. When you don't set MTU, wg-quick takes the MTU of the route to the endpoint (or 1500) and subtracts 80. On a normal link that gives 1420. The 80 bytes cover the worst case: an IPv6 outer header plus UDP and WireGuard overhead. PPPoE or nested tunnels can push the real path lower. The typical symptom is that ping and SSH work but HTTPS pages hang or QUIC connections stall. A don't-fragment ping through the tunnel (1392 bytes of payload plus 28 bytes of headers = 1420) only checks the tunnel MTU itself. The DF bit is set on the inner packet, not on the encrypted UDP packet that crosses the internet, so this ping can succeed even when the path is too small:

ping -M do -s 1392 -c 3 10.8.0.1

To check the underlying path, send a DF ping from the client to the server's public IPv4 address, outside the tunnel. With a 1420 tunnel MTU, that is 1420 + 80 - 28 = 1472 bytes of payload:

ping -4 -M do -s 1472 -c 3 vpn.example.com

If that fails and smaller sizes pass, or if HTTPS or QUIC still stall, set MTU = 1380 (or lower) under the client's [Interface]. The server's MSS clamp covers forwarded TCP, but UDP-based protocols such as QUIC still need a correct MTU.

Alternative: systemd-networkd instead of wg-quick

If networkd already manages the server, use it rather than wg-quick. Don't run both for wg0. networkd reads keys from files that the systemd-network user must be able to read:

install -m 0640 -o root -g systemd-network /etc/wireguard/keys/server.key /etc/systemd/network/wg0.key
install -m 0640 -o root -g systemd-network /etc/wireguard/keys/client1.psk /etc/systemd/network/wg0-client1.psk
[NetDev]
Name=wg0
Kind=wireguard

[WireGuard]
PrivateKeyFile=/etc/systemd/network/wg0.key
ListenPort=51820

[WireGuardPeer]
PublicKey=<contents of client1.pub>
PresharedKeyFile=/etc/systemd/network/wg0-client1.psk
AllowedIPs=10.8.0.2/32,fd42:42:42::2/128
[Match]
Name=wg0

[Network]
Address=10.8.0.1/24
Address=fd42:42:42::1/64

Apply with networkctl reload and check with networkctl status wg0 and wg show wg0. By default RouteTable is off, so networkd adds no routes for peer AllowedIPs. On the server that's fine, because the /24 on wg0 already covers them.

Key rotation and revoking a device

Keep wg0.conf authoritative and push changes with syncconf. It only applies the differences, so the other peers keep their sessions:

cd /etc/wireguard/keys
umask 077
wg genpsk > client1.psk
# paste the new PSK into /etc/wireguard/wg0.conf and into the client config, then:
wg syncconf wg0 <(wg-quick strip wg0)
wg show wg0 latest-handshakes

The client stays disconnected until it has the new PSK, so rotate during a window when you can update it. To rotate a client key, the client generates a new pair and you swap the PublicKey in its [Peer] block. To revoke a lost laptop immediately:

client1_pub="$(cat /etc/wireguard/keys/client1.pub)"
wg set wg0 peer "$client1_pub" remove

Then delete the block from wg0.conf as well, or it comes back on the next restart. Rotating the server key forces every client to update at once, so plan it as maintenance.

Troubleshooting a handshake that never completes

WireGuard doesn't respond at all to packets it can't authenticate. A wrong key gives you no error, just silence. Work through it from the outside in:

  1. Packets arriving? Run tcpdump -ni eth0 udp port 51820 on the server while the client pings. Nothing shows up: check the Endpoint host and port, your provider's cloud firewall, or a network that blocks UDP.
  2. Dropped by nftables? See whether the input dropped counter in nft list chain inet filter input goes up with each attempt. Then check that $WAN matches the real interface name.
  3. Arriving but no reply? That's a key problem. Compare wg show wg0 peers on the server with wg pubkey < client1.key on the client, and the server's public key the other way round. A mismatched PresharedKey fails just as quietly.
  4. Handshake OK, no internet? Check forwarding (sysctl net.ipv4.ip_forward, and look again after a reboot), the forward dropped counter, and the masquerade rule with nft list table inet wg_nat.
  5. Handshake OK, only some traffic works? Look at AllowedIPs on both sides (a missing /32 on the server, or a too-narrow prefix on the client) and at MTU.

Takeaways

  • Debian 13 ignores /etc/sysctl.conf. Put forwarding in /etc/sysctl.d/, set accept_ra = 2 on SLAAC uplinks, and check after a reboot.
  • Generate keys under umask 077, keep private keys out of the config, and give every peer its own PresharedKey.
  • Use a default-drop input and forward, masquerade only tunnel sources, and use destroy table instead of flush ruleset so other tools' tables survive.
  • Apply firewall changes with a systemd-run rollback timer and test from a fresh SSH session.
  • Verify each step with wg show latest-handshakes, tcpdump on both interfaces, and ip route get for v4 and v6.
  • Full tunnel means 0.0.0.0/0, ::/0. Leaving out ::/0 leaks IPv6.
  • Rotate PSKs with wg syncconf, and revoke lost devices both in the live interface and in the config file.

Sources

Comments