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.
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.
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.
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.
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.
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.confProbably 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.
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 openresolvThis 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 disableWireGuard 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.
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)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/wireguardOn 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/wireguardThe 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.
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.confThis 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/32The %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/32Where:
/etc/wireguard/server_private.key file on the server.Endpoint, and in your
cloud provider’s security group./etc/wireguard/public.key file on that client computer.wg-quick runs when the
interface comes up and before it goes down. These are where all of the
firewall work happens.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/wireguardMASQUERADE 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 || trueYou 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.
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 -pNow if you list the rules in the POSTROUTING chain of the NAT table by
using the following command:
iptables -t nat -L POSTROUTINGyou 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
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 bind9Once it’s installed, BIND will automatically start. You can check its status with:
systemctl status bind9Sample 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 bind9Check that lookups still work:
curl -4 icanhazip.comSee which DNS server you are in fact using:
dig icanhazip.comYou will notice that you are not yet using your own bind9.
Edit the BIND DNS server’s configuration file:
nano /etc/bind/named.conf.optionsMake 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/namedAdd -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.1Your 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 ACCEPTRun the following command on the server to start WireGuard:
wg-quick up wg0To stop it, run:
wg-quick down wg0You can also use the systemd service to start WireGuard:
systemctl start wg-quick@wg0.serviceEnable auto-start at system boot time:
systemctl enable wg-quick@wg0.serviceCheck its status with the following command:
systemctl status wg-quick@wg0.serviceinactive (dead) — and why that is correctwg-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:
failed. A non-zero exit from wg-quick puts
it in failed and prints the reason.wg show must list the interface and its peers.# 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.
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.
apt -qy install openresolv
nano /etc/wireguard/wg-client0.confThe 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 = 25Where:
Address: the private IP address of the VPN client. It is a /32,
because the client is a single host; a /24 here would make the client
think the whole VPN subnet is on-link and would break routing to the
server.DNS: specifies 10.10.10.1 (the VPN server) as the DNS server. It is
applied via the resolvconf command, which is why you need
openresolv. You can also specify multiple DNS servers for redundancy
like this: DNS = 10.10.10.1 «77.88.8.8»PrivateKey: the client’s private key, which can be found in the
/etc/wireguard/private.key file on the client computer.PublicKey: the server’s public key, which can be found in the
/etc/wireguard/server_public.key file on the server.AllowedIPs: 0.0.0.0/0 represents the whole IPv4 Internet, which means
that all IPv4 traffic should be routed via the VPN. ::/0 would
represent the whole IPv6 Internet — and we deliberately do not list
it. If you list only one of the two and your client has both IPv4 and
IPv6 capability, only half your traffic goes through the VPN, and the
other half leaks. Omitting ::/0 means WireGuard will not accept or
originate any IPv6 through the tunnel at all, which is exactly what we
want; the client-side nftables rules in
Step 2 stop IPv6 from leaking around the
tunnel instead of through it.Endpoint: the public IP address and port number of the VPN server.
Replace «123.45.67.89» with your server’s real public IP address and
«51820» with your server’s real port number.PersistentKeepalive: sends an authenticated empty packet to the peer
every 25 seconds to keep the connection alive. If PersistentKeepalive
isn’t enabled, the VPN server might not be able to reach the VPN client,
because the client’s NAT mapping will have expired.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/wireguardStart WireGuard:
wg-quick up wg-client0To stop it, run:
wg-quick down wg-client0You 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.serviceThe 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.
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.
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/*.confThen 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 defaultAnd, 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.
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 || trueWrite 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 WireGuardIf 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 rulesetYou should see table inet killswitch with policy drop on all three
chains.
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.
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 = 25PersistentKeepalive 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.
systemctl stop wg-quick@wg-client0.service
wg-quick up wg-client0Then re-run the verification tests — in particular the IPv6 leak test, which should now fail, and the tunnel-down test in VPN Kill Switch.
Run these on the client, in order, after every change.
1. Is the interface up, and does it have a peer?
wg showinterface: 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.13. Has your public IPv4 address changed to the server’s?
curl -4 https://icanhazip.comIt 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.comThis 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 -ltnThen, from the server, connect to it by its tunnel address:
nc -zv 10.10.10.«4» 22 # or any port ss listedIt 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 input8. 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).
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 tsharkDuring 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:
Endpoint port does not match the server’s ListenPort;iptables rule on the server is dropping the packet — check with
iptables -L INPUT -n -v and iptables -L FORWARD -n -v;wg0 interface is not actually up — check with wg show.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.
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 -vIf 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/controlA non-root user cannot enable kernel logging.
Then you can view the debug logs with:
sudo dmesg -wHor:
sudo journalctl -kfIf your VPN still doesn’t work, try restarting the VPN server and client.
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/32Each 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 wg0Then add the WireGuard configuration on each VPN client as usual.
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» removeThis 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.
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.confEvery «…» 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.
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.
These three features interact. Pick deliberately, and understand what you have chosen:
AllowedIPs decides what
WireGuard will accept into the tunnel, while Table and ip rule decide
what the kernel sends to it, and the two can disagree.nftables firewall
from Step 2. It is not compatible with split
tunneling: split tunneling deliberately sends some traffic out the physical
interface by the normal route, and the kill switch drops everything on the
physical interface except the LAN and the tunnel’s own UDP. If you split
tunnel, you must punch matching holes in the firewall, and you give up the
“tunnel down means Internet down” guarantee for the split-out traffic.ip rule entries at priorities 500, 501, and 502. They coexist
happily with a full-tunnel AllowedIPs = 0.0.0.0/0, because they only carve
out your local destination prefixes and leave the default route alone. That
combination is what this tutorial ships.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.confTable = 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.
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.confChange:
AllowedIPs = 0.0.0.0/0to:
AllowedIPs = 10.10.10.0/24So 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.serviceBe 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.
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.
Confirm the tunnel is up and carrying your traffic:
curl -4 --max-time 5 https://icanhazip.comThis 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.comIt 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 LANBring 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 againThis 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.