logo
vision scalability social networking revelation source

WireGuard

abstract: Setting up your own VPN using WireGuard and a Debian server in the cloud. The server and tunnel are tested on Debian bullseye, bookworm, and trixie; the client’s nftables kill-switch firewall is written and tested on trixie and needs care on the older releases.

This tutorial is largely taken from LinuxBabe. It is slightly more up to date than her version at the time of writing, and it has grown a good deal of material of my own, but much of it is hers, and it remains a derived work. See the note on attribution at the end.

1 WireGuard VPN

OpenVPN uses SSL (not to be confused with SSH). WireGuard uses algorithms and code developed by privacy advocates. SSL has numerous well-known vulnerabilities. Note, though, that the classic attack on SSL — active interception by any group that has a certificate authority in its pocket — does not apply to OpenVPN, because OpenVPN does not use the public CA hierarchy. It uses a private CA that you generate yourself, so a rogue public CA cannot man-in-the-middle your tunnel. What OpenVPN does inherit from TLS is a far larger attack surface: OpenSSL has an enormous codebase that is impossible to audit, with an architecture that seems designed for hiding obfuscated vulnerabilities, and the NSA has contributed much to its codebase through innumerable proxies, who are evasive when I talk to them.

WireGuard uses cryptographic libraries developed by our allies, rather than our enemies.

WireGuard is lightweight and fast, blowing OpenVPN out of the water.

OpenVPN is a gigantic pile of code, a maze of strange architectural decisions that slow things down, and which vast complicated pile of incomprehensible things seems to provide no useful purpose other than places to hide backdoors in. I have tried to audit OpenVPN myself. It is suspiciously difficult to audit, and difficulty of audit is precisely the property that makes a backdoor worth hiding. It is a very large pile of spaghetti, and it is hard to audit spaghetti.

Auditability was a major design objective of WireGuard, and it is not an entirely trivial objective to accomplish. Those who created it had, like me, concerns about the auditability of OpenVPN. Legitimate security concerns about OpenVPN tend to be confidently and glibly dismissed by the overly confident and bombastic voice of authority, and I do not find that reassuring.

WireGuard is open-source and cross-platform. It can run on Linux, BSD, macOS, Windows, Android, iOS, and OpenWrt.

User authentication is done by exchanging public keys, similar to SSH keys.

WireGuard assigns static tunnel IP addresses to VPN clients.

Mobile devices can switch between Wi-Fi and a mobile network seamlessly without dropping any connectivity.

It supersedes OpenVPN and IPsec for most purposes, and its codebase is small enough to actually read.

2 Requirements

I assume you have a host in the cloud, with a world-accessible network address and open ports, that can access blocked websites freely outside of your country or Internet filtering system.

One convention before we start. Text inside guillemets — the angle quotes «like this» — is a placeholder. Substitute your own real value, and delete the guillemets themselves. The Latin tag for this is mutatis mutandis, “changing that which ought to be changed”, and it is used as a reminder at a couple of points below. Watch out for those «…». Or, as those who want to baffle you would say, metasyntactic variables are enclosed in «…».

Open the WireGuard UDP port in your cloud provider’s console. This is the single most common reason a tunnel will not come up, and it is the first thing to check. Cloud security groups and network ACLs are entirely separate from any firewall running inside your virtual machine, and diagnosing a blocked UDP port from inside the machine is very difficult. Open UDP port «51820» before you start.

2.1 Debian releases

The VPN server runs Debian. The server side of this tutorial — WireGuard, NAT, and BIND — has no known problems on trixie (Debian 13), bookworm (Debian 12), or bullseye (Debian 11), and machines set up from it are running, or have been running, on all three. It is not going to work on Debian 10 or lower.

The client side is a narrower claim. The kill-switch firewall in Step 2 is written in native nftables and tested on trixie, where nftables is the default firewall backend. The nftables package and its boot service exist on bookworm and bullseye too, and the ruleset will load on both, but on those older releases WireGuard’s own tooling still drives iptables-nft, and any other firewall manager you have configured — ufw, iptables-persistent/netfilter-persistent, or a distribution default — will collide with it. On bookworm and bullseye, make sure nftables.service is the only thing managing your firewall before you proceed.

Accessing your VPN from a Windows client is easy, since the Windows WireGuard client is very friendly. Setting up a WireGuard server on Windows is, on the other hand, very difficult. Don’t even try. I am unaware of anyone succeeding.

2.2 IPv6

We are deliberately not going to carry IPv6 through the tunnel.

IPv6 over a WireGuard tunnel requires that you hand each client a subrange of the host’s own IPv6 prefix — a prefix you may not know, and which your provider may change underneath you without telling you. It works on some hosts. On many others it simply does not work, depending on arcane, incomprehensible, always different, and inaccessible aspects of the provider’s networking setup. Where IPv6 network address translation would be needed, you probably will not be able to get it working anyway, because if it is needed, it is needed because the host network is doing something too clever by half with IPv6 — and you don’t know what they are doing, and they probably do not know either.

So: the tunnel carries IPv4 only, and we will configure the client to keep IPv6 off the tunnel entirely and to stop IPv6 leaking around it. IPv6 is allowed only to and from the local net. Any IPv6 going out to the wider Internet, or arriving from the wider Internet, is dropped. The relevant rules live in the persistent nftables firewall in Step 2, not in the WireGuard configuration file, because they have to hold even when the tunnel is down.

Your server may well have perfectly good IPv6 of its own for its own purposes. That is fine, and we do not disturb it. We simply do not forward it into the tunnel.

2.3 Name service

No end of people are strangely eager to provide free name service. If it is a free service, you are the product. And some of them have sneaky ways to get you to use their name service whether you want it or not.

Name service reveals which websites you are visiting. We are going to set up our own name server for the VPN clients, but it will have to forward to a bigger name server, thus revealing which websites the clients are visiting, though not which client is visiting them. Lots of people are strangely eager to know which websites you are visiting. If you cannot control your name service, then when you set up your own name server, it is likely to behave strangely.

No end of people’s helpful efforts to automatically set up name service for you are likely to foul up the name service of your VPN clients.

cat /etc/resolv.conf

Probably at least two of them are Google, which logs everything and shares the data with the Global American Empire, and the other two are mystery meat. Maybe they are good guys provided by your good-guy ISP, but I would not bet on it. Your ISP probably went along with his ISP, and his ISP may be in the pocket of your enemies.

I use Yandex.com resolvers, since Russia is, at the time of writing, in a state of proxy war with the Global American Empire, and I do not care if the Russian government knows which websites I visit, because it is unlikely to share that data with the Five Eyes.

So, for me:

cat /etc/resolv.conf
nameserver 2a02:6b8::feed:0ff
nameserver 2a02:6b8:0:1::feed:0ff
nameserver 77.88.8.8
nameserver 77.88.8.1

Of course, your mileage may vary, depending on which enemies you are worried about, and what the political situation is when you read this (it may well change radically in the near future). Read up on the resolver’s privacy policies, but apply appropriate cynicism. Political alignments and vulnerability to power matter more than professed good intentions.

We are going to change this when we set up our own name server for the VPN clients, but if you don’t have control of it, things are likely to get strange.

You cannot necessarily change your name servers by editing /etc/resolv.conf, since no end of processes are apt to rewrite that file during boot-up. Changing your name servers depends on how your Linux is set up, but editing /etc/resolv.conf currently works on the standard distribution — though it may well cease to work when you add more software.

If it does not work, maybe you need to subtract some software, but it is hard to know what software. A clean fresh install may be needed.

It all depends on which module, of far too many modules, gets the last whack at /etc/resolv.conf on boot. Far too many people display a curious and excessive interest in controlling which name server you are using, and if they have their claws in your Linux distribution, you are going to have to edit the configuration files of that module.

If something is whacking your /etc/resolv.conf, install openresolv, which will generally make sure it gets the last whack, and edit its configuration files. Or install a distribution where you can control name service by editing /etc/resolv.conf.

2.4 openresolv on the client

openresolv provides the resolvconf command. Without it, the DNS = line in your client configuration file is silently ignored, and your client will keep using your ISP’s resolver — leaking every name you look up straight past the tunnel. Install it on every Linux client:

apt install -qy openresolv

2.5 A note on firewalls

This tutorial does not use ufw, and — unusually — it does not put the client’s firewall in the WireGuard *.conf file’s PostUp and PreDown directives either. The client firewall is a native nftables ruleset, loaded at boot by nftables.service, completely independently of WireGuard.

This is deliberate, and it is the reverse of the common advice. The client firewall here is a kill switch: nothing may reach or leave the machine except over the LAN or through the tunnel, and if the tunnel is down then the Internet is down. A rule that exists only while the tunnel is up cannot enforce that. PostUp runs when the tunnel comes up and PreDown deletes the rules when it goes down, so a PostUp/PreDown firewall is present exactly when you do not need it and absent exactly when you do — the moment the tunnel drops and your default route reverts to the ISP is the moment the rules have been torn down. The guarantee has to be enforced by a firewall that is in force whether or not WireGuard is running, which means a persistent ruleset owned by the boot process, not by the tunnel.

That leaves the WireGuard configuration file to do only the two jobs it is genuinely the right place for: routing (send the default route into the tunnel) and policy that means something only while the tunnel exists (which ports to refuse outbound over the VPN). Both are covered in Step 2.

One consequence of the wall is worth stating up front, because it is easy to read the ruleset the wrong way round. The wall faces the physical interface. Over the physical interface every incoming port is closed to the Internet and open to the local net; over the tunnel every incoming port is open. The tunnel is not left undefended — it is defended at the other end, by the server, which forwards nothing by default and which is the only place that knows which ports you deliberately exposed. See what that firewall does.

The server still uses plain iptables in its own wg0.conf, and that is fine: the server’s rules are about NAT and forwarding for a tunnel that is always up, not about containing leaks when a tunnel is down. Only the client needs the persistent firewall.

If you have ufw installed and enabled, or iptables-persistent, or any other firewall manager on the client, disable it before following this tutorial, or it will fight the nftables ruleset:

ufw disable

2.6 MTU

WireGuard adds 80 bytes to every packet. If your physical interface has the usual 1500-byte MTU, then a tunnel MTU of 1500 produces 1580-byte packets on the wire, which are fragmented or dropped somewhere in the middle, and path MTU discovery frequently fails silently. The symptom is a tunnel that comes up, handshakes, and pings fine — but large web pages hang and SSH sessions freeze after the banner.

The standard remedy is to set MTU = 1420 in both the server and the client configuration files, as shown below.

3 Install WireGuard

On the server and on each Linux client:

apt update -qy
apt full-upgrade -qy
apt install -qy wireguard wireguard-tools linux-headers-$(uname -r)

3.1 Generate Public/Private Key Pairs

On the server:

sudo mkdir -p /etc/wireguard
wg genkey | sudo tee /etc/wireguard/server_private.key | wg pubkey | sudo tee /etc/wireguard/server_public.key
sudo chmod 600 -R /etc/wireguard
sudo chmod 700 /etc/wireguard

On the client:

sudo mkdir -p /etc/wireguard
wg genkey | sudo tee /etc/wireguard/private.key | wg pubkey | sudo tee /etc/wireguard/public.key
sudo chmod 600 -R /etc/wireguard
sudo chmod 700 /etc/wireguard

The chmod 600 -R makes every file, including the directory itself, owner-read-only; the second chmod 700 then restores the execute bit on the directory so that root can still traverse it. Private keys are supposed to be private.

4 Configure WireGuard on the Server

4.1 Create the WireGuard Server Configuration File

Use a command-line text editor like nano to create the configuration file on the Debian server. wg0 will be the network interface name.

sudo nano /etc/wireguard/wg0.conf

This example is for four clients, one of which is a bitcoin peer for which port forwarding is provided. Each client gets a NAT-translated IPv4 address. No client gets an IPv6 address, for the reasons given in IPv6 above. Adjust to taste.

Copy the following text and paste it into your configuration file. You need to use your own server private key and your own client public keys. Remember that the guillemets «…» mark placeholders, mutatis mutandis — you substitute your own values and delete the guillemets. The private key shown below is a placeholder like any other, and everyone now knows it.

[Interface]
# PublicKey = «CHRh92zutofXTapxNRKxYEpxzwKhp3FfwUfRYzmGHR4=»
Address = 10.10.10.1/24
ListenPort = «51820»
PrivateKey = «iOdkQoqm5oyFgnCbP5+6wMw99PxDb7pTs509BD6+AE8=»
MTU = 1420

# ---- NAT and forwarding for the tunnel ----
PostUp = iptables -t nat -A POSTROUTING -s 10.10.10.0/24 -o «eth0» -j MASQUERADE
PostUp = iptables -A FORWARD -i %i -o «eth0» -j ACCEPT
PostUp = iptables -A FORWARD -i «eth0» -o %i -m state --state RELATED,ESTABLISHED -j ACCEPT

# ---- let VPN clients reach the server itself (bind9, and anything else you run) ----
PostUp = iptables -A INPUT -i %i -j ACCEPT

# ---- never forward IPv6 into or out of the tunnel ----
PostUp = ip6tables -A FORWARD -i %i -j DROP
PostUp = ip6tables -A FORWARD -o %i -j DROP
PostUp = ip6tables -A INPUT -i %i -j DROP

PreDown = iptables -t nat -D POSTROUTING -s 10.10.10.0/24 -o «eth0» -j MASQUERADE || true
PreDown = iptables -D FORWARD -i %i -o «eth0» -j ACCEPT || true
PreDown = iptables -D FORWARD -i «eth0» -o %i -m state --state RELATED,ESTABLISHED -j ACCEPT || true
PreDown = iptables -D INPUT -i %i -j ACCEPT || true
PreDown = ip6tables -D FORWARD -i %i -j DROP || true
PreDown = ip6tables -D FORWARD -o %i -j DROP || true
PreDown = ip6tables -D INPUT -i %i -j DROP || true

[Peer]
PublicKey = «rtPdw1xDwYjJnDNM2eY2waANgBV4ejhHEwjP/BysljA=»
AllowedIPs = 10.10.10.4/32

[Peer]
PublicKey = «YvBwFyAeL50uvRq05Lv6MSSEFGlxx+L6VlgZoWA/Ulo=»
AllowedIPs = 10.10.10.8/32

[Peer]
PublicKey = «XpT68TnsSMFoZ3vy/fVvayvrQjTRQ3mrM7dmyjoWJgw=»
AllowedIPs = 10.10.10.12/32

[Peer]
PublicKey = «f2m6KRH+GWAcCuPk/TChzD01fAr9fHFpOMbAcyo3t2U=»
AllowedIPs = 10.10.10.16/32

The %i in those lines is expanded by wg-quick to the interface name that the configuration file names — here, wg0.

Here is the minimal single-client, IPv4-only version of the same thing, if you want somewhere simpler to start:

[Interface]
Address = 10.10.10.1/24
ListenPort = «51820»
PrivateKey = «cD+ZjXiVIX+0iSX1PNijl4a+88lCbDgw7kO78oXXLEc=»
MTU = 1420
PostUp = iptables -t nat -A POSTROUTING -s 10.10.10.0/24 -o «eth0» -j MASQUERADE
PreDown = iptables -t nat -D POSTROUTING -s 10.10.10.0/24 -o «eth0» -j MASQUERADE || true
PostUp = iptables -A FORWARD -i %i -o «eth0» -j ACCEPT
PreDown = iptables -D FORWARD -i %i -o «eth0» -j ACCEPT || true
PostUp = iptables -A FORWARD -i «eth0» -o %i -m state --state RELATED,ESTABLISHED -j ACCEPT
PreDown = iptables -D FORWARD -i «eth0» -o %i -m state --state RELATED,ESTABLISHED -j ACCEPT || true

[Peer]
PublicKey = «AYQJf6HbkQ0X0Xyt+cTMTuJe3RFwbuCMF46LKgTwzz4=»
AllowedIPs = 10.10.10.2/32

Where:

Change the file permission mode so that only the root user can read the files:

sudo chmod 600 -R /etc/wireguard
sudo chmod 700 /etc/wireguard

4.2 Port forwarding for a bitcoin or Monero peer

MASQUERADE is NAT packet translation. It puts your forwarded network addresses behind a NAT, so that they appear on the Internet with the network address of the server. Like your home router, this means that your client system behind the NAT has no open ports.

If you want to open some ports — for example bitcoin’s port 8333 so that you can run Bitcoin Core, or the Monero ports — add DNAT rules to the same [Interface] section, and a matching FORWARD accept for each one. The RELATED,ESTABLISHED rule above does not cover a freshly DNAT’d inbound connection, so the explicit accept is required.

PostUp = iptables -t nat -A PREROUTING -i «eth0» -p tcp --dport 8333 -j DNAT --to-destination 10.10.10.«5»:8333
PostUp = iptables -t nat -A PREROUTING -i «eth0» -p udp --dport 8333 -j DNAT --to-destination 10.10.10.«5»:8333
PostUp = iptables -t nat -A PREROUTING -i «eth0» -p tcp --dport 18080 -j DNAT --to-destination 10.10.10.«5»:18080
PostUp = iptables -t nat -A PREROUTING -i «eth0» -p tcp --dport 18089 -j DNAT --to-destination 10.10.10.«5»:18089
PostUp = iptables -A FORWARD -i «eth0» -o %i -p tcp -m multiport --dports 8333,18080,18089 -d 10.10.10.«5» -j ACCEPT
PostUp = iptables -A FORWARD -i «eth0» -o %i -p udp --dport 8333 -d 10.10.10.«5» -j ACCEPT

PreDown = iptables -t nat -D PREROUTING -i «eth0» -p tcp --dport 8333 -j DNAT --to-destination 10.10.10.«5»:8333 || true
PreDown = iptables -t nat -D PREROUTING -i «eth0» -p udp --dport 8333 -j DNAT --to-destination 10.10.10.«5»:8333 || true
PreDown = iptables -t nat -D PREROUTING -i «eth0» -p tcp --dport 18080 -j DNAT --to-destination 10.10.10.«5»:18080 || true
PreDown = iptables -t nat -D PREROUTING -i «eth0» -p tcp --dport 18089 -j DNAT --to-destination 10.10.10.«5»:18089 || true
PreDown = iptables -D FORWARD -i «eth0» -o %i -p tcp -m multiport --dports 8333,18080,18089 -d 10.10.10.«5» -j ACCEPT || true
PreDown = iptables -D FORWARD -i «eth0» -o %i -p udp --dport 8333 -d 10.10.10.«5» -j ACCEPT || true

You do not need to NAT-translate IPv6, and you should not try; we are not forwarding IPv6 at all. You usually have plenty of IPv6 addresses anyway, so you seldom want to NAT them.

4.3 Enable routing

Telling the firewall to forward packets does not, in itself, result in any forwarding. The kernel has to be told separately.

Edit the system kernel configuration file and uncomment the following line:

nano /etc/sysctl.conf
# Uncomment the next line to enable packet forwarding for IPv4
net.ipv4.ip_forward=1

Leave net.ipv6.conf.all.forwarding commented out. We are not forwarding IPv6.

For these changes to take effect:

sysctl -p

Now if you list the rules in the POSTROUTING chain of the NAT table by using the following command:

iptables -t nat -L POSTROUTING

you will not yet see the masquerade rule — it is added by PostUp when the interface comes up. Bring the interface up first (next section), then look again, and you will see it:

:~# iptables -t nat -L POSTROUTING
Chain POSTROUTING (policy ACCEPT)
target     prot opt source               destination
MASQUERADE  all  --  10.10.10.0/24        anywhere

4.4 Install a DNS Resolver on the Server

Since we will specify the VPN server as the DNS server for the clients, we need to run a DNS resolver on the VPN server. We can install the bind9 DNS server.

apt install bind9

Once it’s installed, BIND will automatically start. You can check its status with:

systemctl status bind9

Sample output:

:~$ systemctl status bind9
● named.service - BIND Domain Name Server
     Loaded: loaded (/lib/systemd/system/named.service; enabled; vendor preset: enabled)
     Active: active (running) since Wed 2022-09-21 20:14:33 EDT; 6min ago
       Docs: man:named(8)
   Main PID: 1079 (named)
      Tasks: 5 (limit: 1132)
     Memory: 16.7M
        CPU: 86ms
     CGroup: /system.slice/named.service
             └─1079 /usr/sbin/named -f -u bind

Sep 21 20:14:33 rho.la named[1079]: command channel listening on ::1#953
Sep 21 20:14:33 rho.la named[1079]: managed-keys-zone: loaded serial 0
Sep 21 20:14:33 rho.la named[1079]: zone 0.in-addr.arpa/IN: loaded serial 1
Sep 21 20:14:33 rho.la named[1079]: zone 127.in-addr.arpa/IN: loaded serial 1
Sep 21 20:14:33 rho.la named[1079]: zone 255.in-addr.arpa/IN: loaded serial 1
Sep 21 20:14:33 rho.la named[1079]: zone localhost/IN: loaded serial 2
Sep 21 20:14:33 rho.la named[1079]: all zones loaded
Sep 21 20:14:33 rho.la named[1079]: running
Sep 21 20:14:33 rho.la named[1079]: managed-keys-zone: Initializing automatic trust anchor management for zone '.'; >
Sep 21 20:14:33 rho.la named[1079]: resolver priming query complete

If it’s not running, start it with:

systemctl start bind9

Check that lookups still work:

curl -4 icanhazip.com

See which DNS server you are in fact using:

dig icanhazip.com

You will notice that you are not yet using your own bind9.

Edit the BIND DNS server’s configuration file:

nano /etc/bind/named.conf.options

Make three changes: add a couple of ACLs above the options block (one for your networks, and one for the bogus networks that should never be queried); replace the placeholder forwarders with real ones; and allow recursion only for your own subnets.

After which it should look something like this:

:~# cat /etc/bind/named.conf.options | tail -n 32
acl bogusnets {
    0.0.0.0/8;
    192.0.2.0/24;
    224.0.0.0/3;
    10.0.0.0/8;
    172.16.0.0/12;
    192.168.0.0/16;
};

acl my_net {
    127.0.0.1;
    ::1;
    «your-own-public-IP»;
    10.10.10.0/24;
};

options {
    directory "/var/cache/bind";

    forwarders {
        «77.88.8.8»;
        «77.88.8.1»;
    };

    //==========================
    // If BIND logs error messages about the
    // root key being expired,
    // you will need to update your keys.
    // See https://www.isc.org/bind-keys
    //==========================

    dnssec-validation auto;

    allow-recursion { my_net; };
    blackhole { bogusnets; };
};

There is no listen-on-v6 line. We are not serving IPv6 over the tunnel, so there is nothing for BIND to listen on, and omitting the line avoids a class of confusing failures on hosts whose IPv6 is half-working.

The ACL block is a real ACL, not a placeholder — bogusnets is fine as it stands. «your-own-public-IP» and the two forwarders are placeholders, mutatis mutandis. Do not paste a real routable IP address of your own into a published document.

Note that blackhole { bogusnets; } includes the RFC 1918 ranges, so this resolver will refuse to answer for private names. That is deliberate: your VPN clients should not be asking it about your server’s local network. If you need split-horizon resolution for VPN clients, that is a separate zone configuration and is beyond the scope of this tutorial.

Then edit the /etc/default/named file:

nano /etc/default/named

Add -4 to the OPTIONS so that BIND only ever uses IPv4:

OPTIONS="-u bind -4"

This is worth doing even on a dual-stack host. On a network that supports both IPv6 and IPv4, BIND’s default dual-stack behaviour causes unending havoc and chaos, because it comes as a surprise to other components of the network, and because bind9 crashes on IPv6 information in its configuration files. Since our tunnel carries no IPv6, BIND has no reason to use it.

Save and close the file.

Restart bind9 for the changes to take effect:

systemctl restart bind9
systemctl status bind9
dig -t txt -c chaos VERSION.BIND @127.0.0.1

Your firewall will allow VPN clients to reach bind9, because the PostUp = iptables -A INPUT -i %i -j ACCEPT line in wg0.conf admits everything arriving on the tunnel interface. That is broader than strictly necessary. If you would rather be more restrictive, replace it with rules for only the ports you actually want clients to reach:

PostUp = iptables -A INPUT -i %i -p udp --dport 53 -j ACCEPT
PostUp = iptables -A INPUT -i %i -p tcp --dport 53 -j ACCEPT

4.5 Start WireGuard on the Server

Run the following command on the server to start WireGuard:

wg-quick up wg0

To stop it, run:

wg-quick down wg0

You can also use the systemd service to start WireGuard:

systemctl start wg-quick@wg0.service

Enable auto-start at system boot time:

systemctl enable wg-quick@wg0.service

Check its status with the following command:

systemctl status wg-quick@wg0.service

4.5.1 Why the status says inactive (dead) — and why that is correct

wg-quick@.service is Type=oneshot with no RemainAfterExit. It is a thin wrapper that runs wg-quick up, and once wg-quick up returns, the service process exits and systemd marks the unit inactive (dead). The kernel network interface, and the tunnel itself, keep running quite independently of the unit’s state.

So inactive (dead) is the successful outcome, and it is confusingly and surprisingly so. What matters is:

# systemctl status wg-quick@wg0.service
○ wg-quick@wg0.service - WireGuard via wg-quick(8) for wg0
     Loaded: loaded (/lib/systemd/system/wg-quick@.service; enabled; preset: enabled)
     Active: inactive (dead) since Wed 2026-09-16 01:14:33 UTC; 6min ago

If it ran to completion without returning an error, you are fine. As long as it does not show that it failed with an error, you are fine.

Now the WireGuard server is ready to accept client connections.

5 Configure the WireGuard Client

The client configuration is presented in two steps: first the minimal tunnel, which is enough to check that everything works end to end, and then the hardened version, which stops the leaks.

5.1 Step 1 — a minimal working tunnel

apt -qy install openresolv
nano /etc/wireguard/wg-client0.conf

The DNS = line makes the client use your server’s own resolver rather than leaking your lookups all over the Internet. It only works if openresolv is installed, which is why that is the first command above.

Copy the following text and paste it into your configuration file. You need to use your own client private key and the server’s public key, and your own endpoint and port. Remember: the guillemets «…» mark placeholders, mutatis mutandis.

[Interface]
Address = 10.10.10.«4»/32
DNS = 10.10.10.1
PrivateKey = «cOFA+x5UvHF+a3xJ6enLatG+DoE3I5PhMgKrMKkUyXI=»
MTU = 1420

[Peer]
PublicKey = «kQvxOJI5Km4S1c7WXu2UZFpB8mHGuf3Gz8mmgTIF2U0=»
AllowedIPs = 0.0.0.0/0
Endpoint = «123.45.67.89»:«51820»
PersistentKeepalive = 25

Where:

Save and close the file.

Change the file mode so that only the root user can read the files:

chmod 600 -R /etc/wireguard
chmod 700 /etc/wireguard

Start WireGuard:

wg-quick up wg-client0

To stop it, run:

wg-quick down wg-client0

You can also use the systemd service:

systemctl start wg-quick@wg-client0.service
systemctl enable wg-quick@wg-client0.service
systemctl status wg-quick@wg-client0.service

The status will say inactive (dead) for exactly the reason explained above. That is success, not failure.

Now run the smoke tests in Verification before going on.

5.2 Step 2 — stop the leaks

A bare WireGuard tunnel leaks. It leaks DNS if you have no resolvconf. It leaks when the tunnel drops, because your default route quietly reverts to your ISP. It leaks IPv6 around the tunnel, because IPv6 has its own routes and its own neighbour discovery and does not care what your IPv4 routing table says.

Step 1 relies on routing to send traffic into the tunnel, and routing is a fair-weather friend: the moment the tunnel is not there, the default route points back at your ISP and everything you meant to protect goes out in the clear. What you actually want is a wall, not a detour sign — a firewall that lets this machine talk to its own LAN and to the WireGuard server and to nothing else, so that the tunnel is the only door to the wider Internet. Then the guarantee you asked for falls out: if the tunnel is up, it is the only way to reach or be reached beyond the LAN; if the tunnel is down, you cannot reach or be reached beyond the LAN at all.

The crucial consequence, and the reason this is not done in wg-client0.conf: that wall has to be standing whether or not the tunnel is up. Rules in PostUp are installed only when the tunnel comes up and deleted by PreDown when it goes down, so a PostUp/PreDown firewall is absent in exactly the situation it exists to protect against — tunnel down, traffic leaking out the ISP link. The kill switch therefore lives in a persistent nftables ruleset that the boot process loads independently of WireGuard. WireGuard’s own configuration file is left to do only routing and tunnel-scoped policy.

5.2.1 Gather the values you need

Find the name of your client’s physical network interface:

ip addr | grep BROADCAST
client_network_interface=$(ip addr | grep BROADCAST | sed -r "s/.*:[[:space:]]*([[:alnum:]]+)[[:space:]]*:.*/\1/")
echo $client_network_interface
# ip addr | grep BROADCAST
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
# client_network_interface=$(ip addr | grep BROADCAST | sed -r "s/.*:[[:space:]]*([[:alnum:]]+)[[:space:]]*:.*/\1/")
# echo $client_network_interface
enp0s3

Find the WireGuard server endpoint address and port your client will use — this is the server’s public address, usually somewhere out on the Internet:

grep -i endpoint /etc/wireguard/*.conf

Then find your own local network prefixes. Most home routers hand out 192.168.0.0/24 or 192.168.1.0/24, but list every range you must still be able to reach directly while the tunnel is down:

ip route | grep -v default

And, if your local network has IPv6 at all, find its prefix too:

ip -6 route | grep -v '^default' | grep -v '^fe80'

Look for a global (non-fe80::, non-ff00::) prefix routed via your router, typically a /64, and use it wherever the configuration below says «2001:db8::/64». (2001:db8::/32 is the reserved documentation prefix; it is a placeholder, not a value you can leave in the file.) If your local network has no IPv6, delete the lan_v6 entries and the IPv6 ACCEPT lines and leave the blanket drop policy in place — that is stricter, and therefore better.

Note the deliberate choice of 10.0.0.0/16 rather than 10.0.0.0/8 in the LAN list below. The tunnel itself lives at 10.10.10.x, and that traffic must go through the VPN, not be treated as local and sent out to the LAN. 10.0.0.0/16 covers 10.0.x.x but stops well short of 10.10.x.x, so the tunnel addresses are not swept into the local-net exception.

5.2.2 The persistent nftables firewall

Install nftables if it is not already present, and make sure nothing else is managing your firewall:

apt install -qy nftables
ufw disable 2>/dev/null || true
systemctl disable --now netfilter-persistent 2>/dev/null || true

Write the following to /etc/nftables.conf. Every «...» is a placeholder and must be replaced with your own real value — mutatis mutandis — before this will work.

#!/usr/sbin/nft -f

#
# Persistent default-deny firewall for the WireGuard client: the kill switch.
#
# Loaded at boot by nftables.service, INDEPENDENTLY of WireGuard, so the
# guarantee holds even when the tunnel is down.  Beyond the LAN, the only
# traffic this machine may send or receive is traffic inside the WireGuard
# tunnel, and the only thing allowed onto the physical wire is the tunnel's
# own UDP to the server.  Tunnel down => Internet down.
#

flush ruleset

define phys    = "«enp0s3»"       # your physical interface
define vpn     = "wg-client0"     # your WireGuard interface (the %i in the conf)
define wg_peer = «123.45.67.89»    # your server's public Endpoint address
define wg_port = «51820»           # your server's WireGuard UDP port

# Ranges you must still reach directly when the tunnel is down.  Keep this
# as 10.0.0.0/16, not /8: the tunnel lives at 10.10.10.x and must stay in
# the VPN, not be treated as local.
define lan_v4  = { «192.168.0.0/24», «192.168.1.0/24», «10.0.0.0/16» }
define lan_v6  = { «2001:db8::/64» }   # your LAN's IPv6 prefix, if it has one

table inet killswitch {

    chain input {
        type filter hook input priority 0; policy drop;

        # EVERYTHING that arrives through the tunnel is accepted, on every
        # port and every protocol.  That is deliberate, and it is not an
        # omission.  We control the server, the server DNATs nothing by
        # default, and a packet can only reach this interface at all if you
        # explicitly programmed the server to forward that port to this one
        # client.  The server is the inbound firewall; a per-port deny list
        # here would block the forwards you asked for and stop nothing else.
        #
        # The tunnel is IPv4-only, though: never accept IPv6 that arrived
        # inside it.
        iifname $vpn meta nfproto ipv6 drop
        iifname $vpn accept

        iifname lo accept
        ct state established,related accept

        # The LAN, in full.
        iifname $phys ip  saddr $lan_v4 accept
        iifname $phys ip6 saddr $lan_v6 accept

        # IPv4 link-local multicast (mDNS and friends) and IGMP membership.
        iifname $phys ip daddr 224.0.0.0/24 accept
        iifname $phys meta l4proto igmp accept

        # IPv6 neighbour discovery: link-local, multicast, and the LAN prefix.
        # None of this can originate from the wider Internet.
        iifname $phys ip6 saddr fe80::/10 accept
        iifname $phys ip6 daddr ff02::/8 accept
        iifname $phys icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert,
                                nd-router-solicit, nd-router-advert } accept

        # DHCP: the broadcast address is not in lan_v4, so allow it by port.
        iifname $phys udp sport { 67, 68 } accept

        # The tunnel's own UDP, from the server endpoint only.
        iifname $phys ip saddr $wg_peer udp sport $wg_port accept
    }

    chain forward {
        type filter hook forward priority 0; policy drop;
        # This machine is not a router.  Nothing is forwarded.
    }

    chain output {
        type filter hook output priority 0; policy drop;

        oifname lo accept

        # The tunnel is IPv4-only, and we refuse to carry the legacy
        # file-sharing and remote-desktop ports over it.  These drops sit
        # before the established/related accept, so even an existing session
        # on one of those ports is cut.
        oifname $vpn meta nfproto ipv6 drop
        oifname $vpn tcp dport { 111, 139, 445, 2049, 3389, «4389» } drop
        oifname $vpn udp dport { 111, 137, 138, 2049 } drop

        ct state established,related accept

        # Everything else into the tunnel — the actual VPN traffic.
        oifname $vpn accept

        # The LAN, in full.
        oifname $phys ip  daddr $lan_v4 accept
        oifname $phys ip6 daddr $lan_v6 accept

        # IPv4 link-local multicast (mDNS and friends) and IGMP membership.
        oifname $phys ip daddr 224.0.0.0/24 accept
        oifname $phys meta l4proto igmp accept

        # IPv6 neighbour discovery and multicast on the LAN.
        oifname $phys ip6 daddr { fe80::/10, ff02::/8 } accept
        oifname $phys icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert,
                                nd-router-solicit, nd-router-advert } accept

        # DHCP: broadcast, not covered by lan_v4.
        oifname $phys udp dport { 67, 68 } accept

        # The ONLY hole to the wider Internet: the tunnel's own UDP to the
        # server.  Everything else out the physical interface is denied by
        # the chain policy — including any raw packet that tries to escape
        # via the ISP when the tunnel is down.
        oifname $phys ip daddr $wg_peer udp dport $wg_port accept
    }
}

Load it, and make it come back at every boot:

nft -f /etc/nftables.conf          # apply now
systemctl enable --now nftables    # apply at boot, independent of WireGuard

If nft -f reports a syntax error, fix it before going any further: a ruleset that fails to load leaves you with the default (empty, allow-all) policy, which is the opposite of what you want. Confirm the wall is standing:

nft list ruleset

You should see table inet killswitch with policy drop on all three chains.

5.2.3 What that firewall does

This is a default-deny kill switch. Everything is dropped unless a rule explicitly accepts it, and the accepts are deliberately few:

The LAN, in both directions. Any traffic to or from your local ranges (lan_v4, and lan_v6 for IPv6) is accepted on the physical interface, so routers, printers, and file servers keep working whether or not the tunnel is up. IPv4 link-local multicast and IGMP are allowed so mDNS service discovery still works, and IPv6 neighbour discovery is allowed so the LAN does not fall over — none of which can come from or go to the wider Internet.

The tunnel’s own UDP, and nothing else, to the Internet. The single outbound hole in the wall is UDP to $wg_peer:$wg_port; the single inbound hole is UDP from it. That is the WireGuard tunnel’s encapsulated traffic crossing the physical interface to your server, which — as you observed — is usually out on the Internet. No other packet may leave by the physical interface. So when an application sends to, say, a web server, the packet is routed into wg-client0 (allowed), WireGuard encrypts it, and the resulting UDP datagram to your server slips through the one hole. A raw, unencrypted packet to that same web server has no hole to go through and is dropped.

Tunnel down means Internet down. When WireGuard is not running, wg-client0 does not exist and the default route falls back to your ISP. Any packet that then tries to leave by the physical interface to a non-LAN, non-endpoint address hits the chain’s policy drop. There is no fallback path; the Internet is simply unreachable until the tunnel returns. This is exactly the property a PostUp/PreDown firewall can never provide, because such a firewall is not even installed when the tunnel is down.

You cannot be reached from the Internet except through the tunnel. Over the physical interface, inbound traffic is accepted only if it is established,related, from the LAN, or the server’s own tunnel UDP. Nothing from beyond the LAN can initiate a connection to you there. Every incoming port is closed to the Internet and open to the local net.

Every port is open inbound over the tunnel. Anything that arrives on wg-client0 is accepted, on any port, by any protocol, by the iifname $vpn accept rule. That looks alarming and is not, because of who is doing the filtering. An inbound connection from the Internet terminates at your server, and it only continues down the tunnel to this client if the server has a DNAT rule forwarding that port to this client’s tunnel address (see port forwarding on the server side). The server forwards nothing by default, and it will forward any given port to exactly one client, only if you specifically programmed it to do so. The server is therefore already the inbound firewall, and a better one than a rule here could be: it is the only place that knows which ports were deliberately exposed, and which client each was exposed to. A deny list on the client would stop no attacker who is not already past the server — it would only break the forwards you asked for, and would have to be edited every time you added one.

The asymmetry is the design:

direction physical interface wg-client0
inbound LAN, established,related, and the server’s tunnel UDP only; everything else dropped accepted, all ports
outbound LAN and the tunnel’s UDP only; everything else dropped accepted, less the legacy ports below

The trust boundary is the server, and the tunnel is the only door to it.

IPv6 is contained. IPv6 never enters the tunnel (dropped on oifname $vpn), and never reaches the wider Internet (only the LAN prefix, link-local, and multicast are accepted on the physical interface). There is no IPv6 hole to the Internet, so IPv6 out there is dropped outright — and stays dropped when the tunnel is down, because this firewall does not depend on the tunnel.

DNS cannot leak. There is no accept for port 53 to anywhere except through the tunnel, so a resolver on the far side of the physical interface is simply unreachable. Your lookups go to DNS = 10.10.10.1 inside the tunnel or they do not go at all. No separate DNS-leak rule is needed; the default deny already does it.

Legacy file-sharing and remote desktop are refused over the tunnel. NFS and SMB (TCP 111, 139, 445, 2049; UDP 111, 137, 138, 2049) and RDP/xrdp (TCP 3389 and your relocated «4389» — not a standard service port, just somewhere to put xrdp so it does not collide with a commercial VPN’s WireGuard endpoint) are dropped on oifname $vpn, so they cannot be carried to a curious or hostile exit node. They remain available on the LAN, which the blanket LAN accept already covers. These are outbound rules, and they do not qualify the inbound accept above: a port you deliberately forwarded to this client on the server still reaches it, whatever its number, and the replies still go back down the tunnel.

5.2.4 The WireGuard configuration file

With the wall standing on its own, wg-client0.conf shrinks to routing and nothing else. No iptables, no ip6tables, no leak protection — that is all the firewall’s job now, and it does that job whether or not this file is loaded.

[Interface]
PrivateKey = «your-private-key»
Address = 10.10.10.«20»/32
DNS = 10.10.10.1
MTU = 1420

# wg-quick installs the default route into the tunnel because of
# AllowedIPs = 0.0.0.0/0 below.  Keep its default routing table; do NOT
# use `Table = off`.
#
# The only thing left here is *routing*, not filtering.  These rules force
# the local ranges that are NOT this machine's own connected subnet to use
# the main table (the physical interface) rather than being swallowed by the
# tunnel's default route.  They matter only while the tunnel is up; when it
# is down, main-table routing is already in effect.  The main table must
# already know how to reach each range (it is connected, or you have a
# static route for it via your LAN gateway).  Delete any line for a range
# that is this machine's own connected subnet — that one needs no help.

PostUp = ip rule add priority 500 from all to «192.168.0.0/24» lookup main
PostUp = ip rule add priority 501 from all to «192.168.1.0/24» lookup main
PostUp = ip rule add priority 502 from all to «10.0.0.0/16»    lookup main
PreDown = ip rule del priority 500 || true
PreDown = ip rule del priority 501 || true
PreDown = ip rule del priority 502 || true

[Peer]
PublicKey = «your-peer-public-key»
AllowedIPs = 0.0.0.0/0
Endpoint = «123.45.67.89»:«51820»
PersistentKeepalive = 25

PersistentKeepalive is 25 throughout this tutorial. Any other value works, but 25 is the conventional default and there is no reason to invent a different one.

5.2.5 Restart the client

systemctl stop wg-quick@wg-client0.service
wg-quick up wg-client0

Then re-run the verification tests — in particular the IPv6 leak test, which should now fail, and the tunnel-down test in VPN Kill Switch.

6 Verification

Run these on the client, in order, after every change.

1. Is the interface up, and does it have a peer?

wg show
interface: wg-client0
  public key: «...»
  private key: (hidden)
  listening port: «41234»
  mtu: 1420

peer: «kQvxOJI5Km4S1c7WXu2UZFpB8mHGuf3Gz8mmgTIF2U0=»
  endpoint: «123.45.67.89»:«51820»
  allowed ips: 0.0.0.0/0
  latest handshake: 12 seconds ago
  transfer: 1.44 MiB received, 210.33 KiB sent
  persistent keepalive: every 25 seconds

latest handshake should be a small and increasing-then-resetting number of seconds. If it keeps growing past about two minutes, your tunnel is not actually working, whatever everything else says. transfer should be non-zero in both directions.

2. Can you reach the server through the tunnel?

ping -c 3 10.10.10.1

3. Has your public IPv4 address changed to the server’s?

curl -4 https://icanhazip.com

It should print your VPN server’s public IP address, not your client computer’s. Note the https — checking your anonymity over plain HTTP is self-defeating.

4. Is DNS going through the tunnel?

cat /etc/resolv.conf
resolvconf -l
dig +short icanhazip.com @10.10.10.1

/etc/resolv.conf should list 10.10.10.1, and resolvconf -l should show that openresolv put it there on behalf of wg-client0. The dig line queries your server’s bind9 directly through the tunnel, and should return an address. For a thorough leak test, open https://www.dnsleaktest.com in a browser and run the extended test; every resolver it reports should belong to your VPN server, and none to your ISP.

5. Is IPv6 contained?

curl -6 --max-time 5 https://icanhazip.com

This should fail with a timeout or a network-unreachable error. If it succeeds and prints an address, your IPv6 is leaking around the tunnel and Step 2 of the client configuration has not been applied correctly.

6. Does large-packet traffic work? (the MTU test)

ping -M do -s 1392 -c 3 icanhazip.com
curl -4 -sS -o /dev/null https://www.kernel.org/

A 1392-byte payload plus the 28 bytes of ICMP and IP headers makes exactly 1420 bytes, which is the tunnel MTU; -M do sets the don’t-fragment bit, so the packet cannot be silently fragmented on the way. A tunnel that pings with small packets but will not load a large page is an MTU problem. See MTU.

7. Are inbound ports open on the tunnel interface?

Find a port the client is listening on:

ss -ltn

Then, from the server, connect to it by its tunnel address:

nc -zv 10.10.10.«4» 22      # or any port ss listed

It should connect. A connection arriving from the server on wg-client0 travels exactly the same path as a DNAT’d connection forwarded from the Internet, so this tests the iifname $vpn accept rule without needing a forward set up. If it is refused while ping 10.10.10.«4» works, the input chain has lost its tunnel accept — check with:

nft list chain inet killswitch input

8. Is the physical interface still closed to the Internet?

The converse test, and the one that matters for safety. From any host on the Internet other than your server, try to connect to that same listening port on the client’s real ISP address. It must time out. If it connects, either the kill switch is not loaded (nft list ruleset) or something else is managing the firewall (ufw status, iptables -L INPUT -n).

7 Troubleshooting

7.1 Check whether UDP port «51820» is open

First, and before anything else: check your cloud provider’s security group or network ACL. It is separate from anything running inside your virtual machine, and it is the most common cause of the symptom below.

To watch the packets themselves, install the tshark network traffic analyzer on the server. tshark is the command-line version of Wireshark.

apt install -qy tshark

During installation you will be asked “Should non-superusers be able to capture packets?”. Answer Yes. If you did, the installer has already done the group membership for you. If you answered No, or want to do it by hand:

adduser «your-username» wireshark
su -l «your-username»
tshark -i «eth0» "udp port «51820»"

If the WireGuard client is able to connect to UDP port «51820» of the server, then you will see packets being captured by tshark like the following. As you can see, the client started the handshake initiation, and the server sent back a handshake response. Once the connection is established, the client sends keepalive packets.

Capturing on 'eth0'
  1 105.092578905 11.22.33.44 → 12.34.56.78 WireGuard 190 Handshake Initiation, sender=0x3F1A04AB
  2 110.464628716 12.34.56.78 → 11.22.33.44 WireGuard 134 Handshake Response, sender=0x34ED7471, receiver=0xD4B23800
  3 110.509517074 11.22.33.44 → 12.34.56.78 WireGuard 74 Keepalive, receiver=0x34ED7471, counter=0

If the WireGuard client cannot connect to UDP port «51820» of the server, then you will only see handshake initiation packets. There’s no handshake response.

Capturing on 'eth0'
   1 105.092578905 11.22.33.44 → 12.34.56.78 WireGuard 190 Handshake Initiation, sender=0x3F1A04AB
    2 149.670118573 11.22.33.44 → 12.34.56.78 WireGuard 190 Handshake Initiation, sender=0x7D584974
        3 152.575188680 11.22.33.44 → 12.34.56.78 WireGuard 190 Handshake Initiation, sender=0x8D2407B9
        4 153.706876729 12.34.56.78 → 11.22.33.44 WireGuard 190 Handshake Initiation, sender=0x47690027
        5 154.789959772 11.22.33.44 → 12.34.56.78 WireGuard 190 Handshake Initiation, sender=0x993232FC
        6 157.956576772 11.22.33.44 → 12.34.56.78 WireGuard 190 Handshake Initiation, sender=0x06AD433B
        7 159.082825929 12.34.56.78 → 11.22.33.44 WireGuard 190 Handshake Initiation, sender=0x8C089E1

If you see initiation but no response, in order of likelihood:

  1. the cloud security group is not open for UDP «51820»;
  2. the client’s Endpoint port does not match the server’s ListenPort;
  3. an iptables rule on the server is dropping the packet — check with iptables -L INPUT -n -v and iptables -L FORWARD -n -v;
  4. the server’s wg0 interface is not actually up — check with wg show.

7.2 Ping test

You can ping from the VPN server to a VPN client (ping 10.10.10.«4») to see if the tunnel works. If you see the following error message:

ping: sendmsg: Required key not available

then the AllowedIPs parameter on the server is wrong — typically a typo, or a client address that does not appear in any peer’s AllowedIPs at all. WireGuard refuses to send a packet for which it has no peer.

If the ping error message is instead:

ping: sendmsg: Destination address required

then the interface has no route for the destination at all — the wg0 interface is down, or has no peers configured.

Note that a wrong key produces neither of these errors. A wrong PrivateKey stops wg-quick from bringing the interface up, with an explicit parse error. A wrong peer PublicKey brings the tunnel up, appears perfectly healthy in the configuration, and then simply times out, because the handshake never completes. If ping times out with no error message at all, and wg show displays no latest handshake, that is the signature of a mismatched key pair.

7.3 Not able to browse the Internet

If the VPN tunnel is successfully established, but the client’s public IP address doesn’t change, then the masquerading or forwarding rule in your wg0.conf file is not working — typically a typo in a PostUp line, or net.ipv4.ip_forward is still 0. Check with:

sysctl net.ipv4.ip_forward
iptables -t nat -L POSTROUTING -n -v

7.4 Enable debug logging in the Linux kernel

If you use Linux kernel 5.6 or later, you can, as root, enable debug logging for WireGuard with the following commands:

sudo su -
echo module wireguard +p > /sys/kernel/debug/dynamic_debug/control

A non-root user cannot enable kernel logging.

Then you can view the debug logs with:

sudo dmesg -wH

or:

sudo journalctl -kf

7.5 Restart

If your VPN still doesn’t work, try restarting the VPN server and client.

8 Adding and Removing VPN Clients

WireGuard associates each IP address with exactly one peer, via that peer’s AllowedIPs. To add more VPN clients, create a unique private/public key pair for each client, then add a [Peer] stanza for each one to the server’s configuration file (/etc/wireguard/wg0.conf), like this:

[Interface]
# The [Interface] section is unchanged — Address, ListenPort, PrivateKey,
# MTU, and every one of the PostUp and PreDown lines, exactly as given in
# "Create the WireGuard Server Configuration File" above.  Only the [Peer]
# stanzas are added.

[Peer]
PublicKey = «75VNV7HqFh+3QIT5OHZkcjWfbjx8tc6Ck62gZJT/KRA=»
AllowedIPs = 10.10.10.2/32

[Peer]
PublicKey = «YYh4/1Z/3rtl0i7cJorcinB7T4UOIzScifPNEIESFD8=»
AllowedIPs = 10.10.10.3/32

[Peer]
PublicKey = «EVstHZc6QamzPgefDGPLFEjGyedJk6SZbCJttpzcvC8=»
AllowedIPs = 10.10.10.4/32

Each VPN client will have a static private IP address (10.10.10.2, 10.10.10.3, 10.10.10.4, and so on). Restart the WireGuard server for the changes to take effect:

wg-quick down wg0
wg-quick up wg0

Then add the WireGuard configuration on each VPN client as usual.

8.1 Removing a client

Deleting the [Peer] stanza from the server’s wg0.conf and restarting is sufficient to lock a client out, but it is not complete revocation: the client still holds a valid key pair, and its own configuration still contains your server’s public key and endpoint. It simply can no longer complete a handshake.

To revoke properly, remove the peer from the running interface:

wg set wg0 peer «client-public-key» remove

This takes effect immediately, without restarting the interface. Then delete the [Peer] stanza from wg0.conf, so that it does not come back on the next restart, and destroy the client’s /etc/wireguard/private.key. Never reuse a client IP address for a different key pair without first removing the old peer, or you will have two peers claiming the same AllowedIPs and WireGuard will quietly route to whichever it likes.

9 Configure a VPN Client on iOS or Android

Install the WireGuard app from the App Store or from your Android app store. Then open the app and click the Add a tunnel button.

You have three methods to create a new WireGuard tunnel:

Create from scratch means that the WireGuard app gives you a private and public key pair, and an empty wg-client.conf file that you populate in the app’s user interface. This is likely to result in a lot of typing, in which you are bound to make a typo — even though the correct and working information is already on your Debian server and client, and you would like to just copy and paste it.

Create from QR code is usually the easiest route, precisely because Android devices are locked down against ordinary users transferring computer data, and a QR code gets around that. Create ios.conf on your Linux client exactly as in Step 1; add its public key to the server’s wg0.conf as usual; restart the server; then generate the QR code with:

apt install -qy qrencode
qrencode -t ansiutf8 < /etc/wireguard/ios.conf

Every «…» placeholder must already have been replaced with your own real value before you encode the file. A QR code containing guillemets will scan perfectly well and then fail to import, or worse, import and silently not work. Mutatis mutandis — check the file for « first:

grep '«\|»' /etc/wireguard/ios.conf && echo "PLACEHOLDERS STILL PRESENT"

qrencode is very useful for transferring data to Android systems, which tend to be locked down against ordinary users transferring computer data.

10 Configure a VPN Client on Windows

Download the WireGuard installer for Windows.

Once it’s installed, start the WireGuard program. You need to right-click on the left sidebar to create a new empty tunnel. It will automatically create a public/private key pair for the Windows client.

From there on, the steps are the same as for the Android client: add the Windows client’s public key to the server’s wg0.conf, restart the server, and populate the tunnel with your Address, DNS, the server’s public key, and the Endpoint.

Note that none of the nftables kill-switch firewall from Step 2 applies on Windows, and there is no equivalent. A Windows client will have DNS leak protection (the app sets the DNS servers itself) but will not have the default-deny wall, the IPv6 containment, the local-network exceptions, or the port blocking, and it will happily fall back to the direct ISP route the moment the tunnel drops. Windows runs loads of undocumented closed-source mystery meat processes that do who knows what, so a Windows client is a considerably less trustworthy VPN endpoint than a hardened Linux one, and it cannot offer the “tunnel down means Internet down” guarantee at all.

On Windows, you can use PowerShell to SSH into your Linux server, so you do not have the file-transfer problem you had with Android.

11 Policy Routing, Split Tunneling, and the VPN Kill Switch

These three features interact. Pick deliberately, and understand what you have chosen:

11.1 Policy Routing

By default, all traffic on the VPN client will be routed through the VPN server. Sometimes you may want to route only a specific type of traffic, based on the transport layer protocol and the destination port. This is known as policy routing.

Policy routing is configured on the client computer, so stop the WireGuard client and edit its configuration file:

systemctl stop wg-quick@wg-client0.service
nano /etc/wireguard/wg-client0.conf

Table = 1234 tells wg-quick to put the tunnel’s routes into routing table 1234 instead of the main table, so that nothing uses them by default. The ip rule line is what then directs matching traffic to that table. If you add the following three lines to the [Interface] section, then in this example traffic will be routed through the VPN server only when TCP is the transport layer protocol and the destination port is 25 — that is, only when the client computer sends email.

Table = 1234
PostUp = ip rule add ipproto tcp dport 25 table 1234
PreDown = ip rule delete ipproto tcp dport 25 table 1234 || true
[Interface]
Address = 10.10.10.2/32
DNS = 10.10.10.1
PrivateKey = «cOFA+x5UvHF+a3xJ6enLatG+DoE3I5PhMgKrMKkUyXI=»
MTU = 1420
Table = 1234
PostUp = ip rule add ipproto tcp dport 25 table 1234
PreDown = ip rule delete ipproto tcp dport 25 table 1234 || true

[Peer]
PublicKey = «kQvxOJI5Km4S1c7WXu2UZFpB8mHGuf3Gz8mmgTIF2U0=»
AllowedIPs = 0.0.0.0/0
Endpoint = «123.45.67.89»:«51820»
PersistentKeepalive = 25

The ipproto and dport selectors for ip rule require kernel and iproute2 version 4.17 or later. On anything older, ip rule add will reject them.

Save and close the file. Then start the WireGuard client again.

11.2 Split Tunneling

By default, all traffic on the VPN client will be routed through the VPN server. Here’s how to enable split tunneling, so that only traffic to the 10.10.10.0/24 IP range is tunneled through the WireGuard VPN. This is useful when you want to build a private network for several cloud servers, because the VPN clients will run on cloud servers, and if you use a full VPN tunnel, then you will probably lose your connection to the cloud servers themselves.

Edit the client configuration file:

nano /etc/wireguard/wg-client0.conf

Change:

AllowedIPs = 0.0.0.0/0

to:

AllowedIPs = 10.10.10.0/24

So traffic will be routed through the VPN only when the destination address is in the 10.10.10.0/24 IP range. Save and close the file. Then restart the WireGuard client:

sudo systemctl restart wg-quick@wg-client0.service

Be aware that split tunneling means your general Internet traffic is not protected. If you are using this VPN to reach blocked websites, split tunneling defeats the entire purpose.

It also collides head-on with the kill switch from Step 2. That firewall drops everything on the physical interface except LAN traffic and the tunnel’s own UDP, so the traffic you meant to split out by the normal route will be dropped, not split. To split tunnel you would have to punch matching holes in /etc/nftables.conf for every destination you send direct — and once you do, those destinations are reachable with the tunnel down, which is exactly the leak the kill switch exists to prevent. Split tunneling and a real kill switch are fundamentally at odds; choose one.

11.3 VPN Kill Switch

The kill switch is not an optional extra you bolt on at the end; it is the persistent nftables firewall you installed in Step 2, and it is already running. Because it is loaded by nftables.service at boot and does not belong to the tunnel, it enforces the guarantee that matters: if the tunnel is up, it is the only way to reach or be reached beyond the LAN; if the tunnel is down, you can neither reach nor be reached beyond the LAN at all.

This is precisely what the old PostUp/PreDown approach could not do. A firewall installed by PostUp exists only while the tunnel is up and is torn down by PreDown when it goes down, so it is missing at the exact moment you need it — when the tunnel has dropped and your default route has quietly reverted to the ISP. The Step 2 firewall is the other way round: it is always in force, and the tunnel is a single UDP hole punched through it to your server. No hole, no traffic.

If you are reading this because you followed an older version of this tutorial that put iptables ... -j REJECT lines into wg-client0.conf, remove them. They are redundant with the persistent firewall at best, and at worst they conflict with it or give you a false sense of security, since they vanish the moment the tunnel does — which is the moment they were supposed to protect you.

11.3.1 Test the kill switch

Confirm the tunnel is up and carrying your traffic:

curl -4 --max-time 5 https://icanhazip.com

This prints your server’s address. Now take the tunnel down — on the client, wg-quick down wg-client0, or from the server, wg-quick down wg0 — and run it again:

curl -4 --max-time 5 https://icanhazip.com

It must fail — time out or report the network as unreachable — and it must never print your real ISP address. If it prints your ISP address, the persistent firewall is not loaded: check nft list ruleset for table inet killswitch with policy drop, and check that nftables.service is enabled.

While the tunnel is down, confirm the LAN still works, which it must:

ping -c 2 «192.168.1.1»      # your router, or another host on the LAN

Bring the tunnel back up and everything beyond the LAN returns:

wg-quick up wg-client0
curl -4 --max-time 5 https://icanhazip.com   # your server's address again

12 Attribution and Licence

This tutorial is a derived work. It began as WireGuard VPN Server on Debian by Xiao Guoan of LinuxBabe, and substantial portions of her prose remain in it, particularly in the sections on IP masquerading, the BIND resolver, the tshark handshake test, the ping error messages, and the mobile and Windows clients.

A great deal has since been added, changed, or removed: the whole client firewall approach (this version uses a persistent, boot-loaded nftables default-deny kill switch rather than ufw or per-tunnel iptables rules in the WireGuard configuration files), the deliberate abandonment of IPv6 over the tunnel, the client hardening and IPv6 containment rules, the verification checklist, and the discussion of auditability. The server still uses plain iptables for NAT and forwarding.

The original is copyright its author and is not licensed for redistribution under a Creative Commons licence. This page is therefore marked notmine: true so that no CC-BY notice is attached to it. If you re-use anything from this page, check the licence of the upstream material before redistributing it.