Setting up a server
Tunl is a client. It connects to a WireGuard server, and if you don't already have one, this page gets you from a bare VPS to a working tunnel. The first half takes about ten minutes. The second half is about not leaving the machine open afterwards, and is worth the extra twenty.
If someone else runs your VPN and handed you a .conf file, you
don't need any of this. Import the file and you're done.
What you need
A Linux machine with a public IP address. The smallest VPS any provider sells is more than enough; WireGuard uses almost no CPU and a few megabytes of memory. Two things are worth a moment's thought before you order:
- The region. Everything you do will leave the internet from this machine, so its country is the country websites see, and its distance from you is the latency you add to every connection. Pick the closest region in the country you want to appear from.
- A name for it. Point a DNS record such as
vpn.example.netat the server's address and use the name, not the number, as the endpoint in Tunl. If you ever move the server, Tunl can re-resolve the name and repair the tunnel (on its own with Premium, with one click on Repair otherwise); a bare IP address in the configuration means editing every device instead.
The commands below are for Debian 12 and Ubuntu 22.04 or newer. They work the
same on x86 and ARM machines. Run them as root, or put sudo in
front of each one.
Install WireGuard and make a key for the server
apt update && apt install -y wireguard iptables ufw
umask 077
wg genkey | tee /etc/wireguard/server.key | wg pubkey > /etc/wireguard/server.pub
cat /etc/wireguard/server.pub
That last line prints the server's public key. You'll paste it into Tunl in a moment.
Write the configuration
Create /etc/wireguard/wg0.conf. Replace
SERVER_PRIVATE_KEY with the contents of
/etc/wireguard/server.key, and eth0 with your
machine's real network interface if it differs (ip route show default
will tell you).
[Interface]
Address = 10.8.0.1/24, fd42:8::1/64
ListenPort = 51820
PrivateKey = SERVER_PRIVATE_KEY
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostUp = ip6tables -A FORWARD -i %i -j ACCEPT; ip6tables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
PostDown = ip6tables -D FORWARD -i %i -j ACCEPT; ip6tables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
The PostUp and PostDown lines are what let your
traffic reach the internet through the server rather than stopping at it.
The second address and the ip6tables lines are about IPv6, which
most home and mobile networks now hand your Mac alongside IPv4. What keeps
that traffic inside the VPN is the client: Tunl's default
AllowedIPs of 0.0.0.0/0, ::/0 sends IPv6 into the
tunnel whether or not the server can carry it, and the kill switch holds it
there too. What these server lines decide is whether that traffic then
works. Without them, IPv6 enters the tunnel and stops, and every
site that prefers IPv6 waits for a fallback before loading. With them, and a
server that has a public IPv6 address, it just works. Do not "fix" a slow
site by trimming the client's AllowedIPs to IPv4 only; that is
the one change that actually opens a leak.
One exception: if IPv6 is disabled on the server, or the kernel lacks the
ip6tables NAT module (some containers and hardened images),
wg-quick up fails as a whole, complaining about the IPv6 address
or about ip6tables, and takes IPv4 down with it. In that case
remove the fd42:8::1/64 address and both ip6tables
lines together. Tunl still keeps IPv6 inside the tunnel; it just stops there.
Turn on forwarding and start it
cat > /etc/sysctl.d/99-wireguard.conf <<'EOF'
net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1
EOF
sysctl --system
systemctl enable --now wg-quick@wg0
wg show
One thing to know about IPv6 forwarding: it makes the kernel stop listening
to router advertisements, and on a server whose IPv6 address comes from those
advertisements the default route quietly expires an hour or a day later. The
Debian 12 and Ubuntu cloud images do not have this problem, because their
network manager handles advertisements itself, in userspace. If you
configured networking by hand with kernel SLAAC and no manager, add
net.ipv6.conf.eth0.accept_ra=2 to the file above, with your
uplink's name in place of eth0; the setting is per interface
and the all key does not reach an interface that already
exists.
wg show should print an interface with a listening port and no
peers yet. Make sure UDP port 51820 is open in your provider's firewall, since
that is separate from the machine's own.
Add yourself as a peer
In Tunl, choose New Tunnel. It generates a key pair, keeps the private half on your Mac, and shows you two things: your public key, and the exact block the server needs. Fill in the server's public key and its address, then open What they add on the server and copy the block. It looks like this:
[Peer]
PublicKey = YOUR_PUBLIC_KEY_FROM_TUNL
AllowedIPs = 10.8.0.2/32, fd42:8::2/128
Add it to the running interface and save, which writes it into
wg0.conf for you:
wg set wg0 peer YOUR_PUBLIC_KEY_FROM_TUNL allowed-ips 10.8.0.2/32,fd42:8::2/128
wg-quick save wg0
Use this pair for every peer rather than editing the file by hand.
wg-quick save rewrites the peer list from what is running, so a
peer that was only pasted into the file, and never loaded, disappears the
next time you save.
Give each device its own key and its own addresses: 10.8.0.2 and fd42:8::2, then 10.8.0.3 and fd42:8::3, and so on. Sharing one key between machines works right up until both are connected at once, and then neither does.
What goes in Tunl's form
| Field | Value |
|---|---|
| Server public key | The line cat /etc/wireguard/server.pub printed. |
| Endpoint | vpn.example.net:51820. A name, so Tunl can re-resolve it. |
| Your address in the tunnel | 10.8.0.2/24, fd42:8::2/64. Tunl narrows both to single hosts in the peer block it shows you. |
| DNS | See the next section. Leave it empty for now if you want to see the handshake first. |
| AllowedIPs (under Routing) | 0.0.0.0/0, ::/0, which is the default. This is what sends everything through the tunnel. |
DNS
If the configuration has no DNS line, your Mac keeps using the
resolver the local network handed it, and the network you were trying to get
away from still sees every name you look up. There are two good answers.
The quick one: put a public resolver in the DNS field, such as
1.1.1.1, 2606:4700:4700::1111. Because everything is routed
through the tunnel, the queries travel inside it and come out at your server;
the local network sees nothing. Then switch on encrypted DNS in Tunl, so the
leg from your server to the resolver is not plaintext either. Tunl uses the
addresses in the DNS field as the encrypted resolver, so the field and the
DoH or DoT hostname have to belong to the same operator: 1.1.1.1 with
cloudflare-dns.com, or 9.9.9.9 with dns.quad9.net,
not one of each. The resolver's operator still sees the names.
The private one: run a resolver on the server, so no third party sees your queries at all. Unbound asks the root servers directly and caches the answers:
apt install -y unbound
cat > /etc/unbound/unbound.conf.d/wireguard.conf <<'EOF'
server:
interface: 10.8.0.1
interface: fd42:8::1
ip-freebind: yes
access-control: 10.8.0.0/24 allow
access-control: fd42:8::/64 allow
EOF
systemctl restart unbound
ufw allow in on wg0 to any port 53
Then set the DNS field in Tunl to 10.8.0.1, fd42:8::1, and leave
Tunl's encrypted DNS off: it would try to speak HTTPS or TLS to Unbound, which
only answers plain DNS, and nothing would resolve. Your queries never leave
the tunnel, which is the point of running the resolver yourself.
Three details in that block are there because the obvious version fails.
Unbound listens on the tunnel addresses only, not on every address: Ubuntu
already has a stub resolver on port 53, and a wildcard bind collides with it
and Unbound refuses to start. ip-freebind lets it bind those
addresses before wg0 exists, so boot order does not matter. And
the ufw rule is needed because the firewall you will set up
below denies incoming traffic on every interface, the tunnel included, and
would otherwise drop your own DNS queries at 10.8.0.1.
Connect
Back in Tunl, click Connect. The Live section should show a handshake within a
second or two, and the byte counters should move when you open a web page.
On the server, wg show prints the same handshake age and transfer
figures from its side, which is the quickest way to tell which half of the
path a problem is on.
When it doesn't
| What you see | Where to look |
|---|---|
| No handshake at all | UDP 51820 closed at the provider's firewall
or in ufw; a typo in one of the two public keys; the endpoint
name not resolving yet. wg show on the server will list your
peer with no handshake. |
| Handshake, but nothing loads | Forwarding is off (check
sysctl net.ipv4.ip_forward), or the interface name in the
PostUp lines is not the one that carries the server's default
route, or the peer's AllowedIPs on the server does not contain
the address the client is using, so the server discards what it sends. The
server sees received bytes climbing and sent bytes staying flat. |
| Addresses work, names don't | DNS. The resolver you set is not
reachable through the tunnel; Unbound is not running (check
systemctl status unbound); the firewall is dropping port 53 on
wg0; or Tunl's encrypted DNS is on while the DNS field points at
a resolver that does not speak it. |
| Most sites load, some hang forever | MTU. Some networks, and
most mobile hotspots, cannot carry WireGuard's default packet size. Add
MTU = 1280 under [Interface] in the tunnel's
configuration in Tunl. |
| Fine when active, drops after a few minutes idle | A router
between you and the server is forgetting the connection. Add
PersistentKeepalive = 25 under [Peer] in the tunnel's
configuration. |
If the network you're on blocks UDP
Some hotel and corporate networks drop UDP entirely, and WireGuard cannot work without it. Tunl can carry the same encrypted traffic inside a WebSocket, but that needs a relay on the server.
Put it on port 443. That is the whole point. A network that blocks UDP will usually block an odd TCP port too; what it cannot block is the port every website uses. On 443 with a real certificate, your tunnel is indistinguishable from someone reading the news.
Get a certificate first
The relay needs a real certificate for the name you gave the server. Certbot issues one over port 80, which has to be open for issuance and for every renewal after it:
apt install -y certbot
ufw allow 80/tcp
certbot certonly --standalone -d vpn.example.net
Install and run it
ARCH=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/')
VER=$(curl -fsSI https://github.com/erebe/wstunnel/releases/latest \
| tr -d '\r' | awk -F/ 'tolower($1) ~ /^location:/ {print $NF}')
curl -fsSL -o /tmp/wstunnel.tar.gz \
"https://github.com/erebe/wstunnel/releases/download/$VER/wstunnel_${VER#v}_linux_$ARCH.tar.gz"
tar -xzf /tmp/wstunnel.tar.gz -C /usr/local/bin wstunnel
chmod +x /usr/local/bin/wstunnel
The second line asks GitHub which release is current, because the file names carry the version number and there is no fixed "latest" name to download.
Run it under systemd so it comes back after a reboot. Create
/etc/systemd/system/wstunnel.service:
[Unit]
Description=wstunnel relay for WireGuard
Wants=network-online.target
After=network-online.target
[Service]
LoadCredential=cert.pem:/etc/letsencrypt/live/vpn.example.net/fullchain.pem
LoadCredential=key.pem:/etc/letsencrypt/live/vpn.example.net/privkey.pem
ExecStart=/usr/local/bin/wstunnel server wss://0.0.0.0:443 \
--websocket-mask-frame \
--restrict-to vpn.example.net:51820 \
--tls-certificate %d/cert.pem \
--tls-private-key %d/key.pem
Restart=always
DynamicUser=yes
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
[Install]
WantedBy=multi-user.target
The two LoadCredential lines are how a service that runs as a
throwaway user gets to read files only root can. systemd reads the
certificate and key as root and hands them to the relay in a private
directory, which %d refers to. Without this you would have to
either run the relay as root or loosen the permissions on your private key,
and neither is a good trade.
systemctl enable --now wstunnel
systemctl status wstunnel --no-pager
cat > /etc/letsencrypt/renewal-hooks/deploy/wstunnel <<'EOF'
#!/bin/sh
systemctl restart wstunnel
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/wstunnel
The last four lines make each automatic certificate renewal restart the relay, so it picks up the new certificate without you. It is a file rather than a flag on the certbot command so that it cannot run before the relay exists. The restart drops whoever is on the relay at that moment for a few seconds, roughly once every two months; Tunl reconnects on its own.
The two flags that decide whether it works
--websocket-mask-frame is required. Tunl uses Apple's networking
stack, which masks WebSocket frames as the standard tells clients to, and
wstunnel drops masked frames without this flag. It does so silently: the
symptom is a tunnel that connects and then carries nothing at all, with no
error anywhere to explain it.
--restrict-to is what keeps the relay from becoming a public
proxy. wstunnel does not verify the destination a client asks for, so without
this anyone who finds the port can use your server to reach anything, from
your own private network to somebody else's.
Give it the same host and port as the Endpoint line in
your client's configuration, because that is what the client asks the
relay to forward to. A loopback address does not work here even though the
relay and WireGuard are on the same machine: the client does not know they
are, so it never asks for one, and the relay answers
Rejecting connection with not allowed destination while the
tunnel retries in a loop.
Why TLS
ws:// without TLS still gets past a firewall that only blocks UDP,
and WireGuard's own encryption still protects your traffic either way. But the
wrapper is then plainly a WebSocket rather than ordinary HTTPS, which defeats
the reason for using it on a network that inspects what you connect to.
Sharing 443 with a website
If the server already runs a site on 443, put wstunnel behind the reverse proxy instead of in front of it. Skip the certbot step in that case: Caddy obtains and renews its own certificate, and certbot's standalone mode would fight it for port 80. Route one path to wstunnel and leave everything else alone. In Caddy:
vpn.example.net {
handle /tunnel/* {
reverse_proxy 127.0.0.1:8080
}
handle {
root * /var/www/html
file_server
}
}
Then run wstunnel on ws://127.0.0.1:8080 without its own TLS.
The unit is the one above minus everything about certificates: no
LoadCredential lines, no renewal hook, and this
ExecStart:
ExecStart=/usr/local/bin/wstunnel server ws://127.0.0.1:8080 \
--websocket-mask-frame \
--restrict-to vpn.example.net:51820
In Tunl give the full address including wstunnel's own path:
wss://vpn.example.net/tunnel/v1/events. Tunl appends
/v1/events for you when you give it a bare address, but it leaves
an explicit path alone, which is what makes this arrangement possible.
Securing the server
Worth saying plainly, because a VPN guide that skips this leaves you worse off than before: all of your traffic now goes through this machine. If somebody gets into it, they are inside everything you do. The tunnel does not protect you from a compromised server, it delivers you to one.
The machine itself
This is the part that actually matters, and it has nothing to do with WireGuard. Almost every VPS that gets taken over is taken over through SSH with a guessable password. Use a key and turn passwords off:
ssh-copy-id [email protected] # from your Mac, before you lock yourself out
# then on the server, in /etc/ssh/sshd_config:
PasswordAuthentication no
PermitRootLogin prohibit-password
systemctl restart ssh
Open a second terminal and confirm you can still log in before you close the first one. Everyone learns this the hard way once.
Close everything you did not open
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 51820/udp
ufw allow 443/tcp # only if you are running the WebSocket relay
ufw allow 80/tcp # only if certbot renews the relay's certificate
ufw enable
Port 53 stays closed: Unbound answers the tunnel from inside the machine and never needs to be reachable from the internet. Your provider probably has its own firewall in front of the machine as well. Both have to allow a port, and people lose an hour to this regularly.
Keys and their permissions
The package makes /etc/wireguard readable only by root, and
wg genkey warns you if your umask would leave a key world
readable. Take the warning seriously; a private key that anyone on the machine
can read is not a private key. Check with:
stat -c '%a %n' /etc/wireguard/*
Both the directory and the key should read 700 and
600. Nothing needs to be backed up except the configuration, and
if you lose a key the fix is to generate a new one and replace the peer.
Why each peer gets a /32
On the server, a peer's AllowedIPs is not a routing convenience.
It is an access control list: WireGuard will accept a packet from that peer
only if its source address falls inside that range. Give a peer
10.8.0.2/32 and it can only ever claim to be 10.8.0.2. Give it
0.0.0.0/0, as people sometimes do by copying the client's side of
the configuration, and it can claim to be anything, including your other
devices.
This is also why every device needs its own key. Two machines sharing one key work fine until both connect, and then the server has one route for two claimants and the traffic goes to whichever handshook last.
Peers can reach each other
By default anything on 10.8.0.0/24 can talk to anything else on it. If the
tunnel is only yours, that is what you want. If you have added family or
colleagues, and you would rather they not see each other's machines, add
these lines to the [Interface] section of wg0.conf,
then restart with systemctl restart wg-quick@wg0 to apply them
(every connected device reconnects on its own):
PostUp = iptables -I FORWARD -i %i -o %i -j DROP; ip6tables -I FORWARD -i %i -o %i -j DROP
PostDown = iptables -D FORWARD -i %i -o %i -j DROP; ip6tables -D FORWARD -i %i -o %i -j DROP
Updates
apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
An unattended server that nobody patches is the most common way a personal VPN turns into somebody else's.
What this does not protect you from
A self-hosted VPN moves the point where your traffic joins the internet from the cafe's router to a machine you rent, under your own name, with your own payment details. That is a real improvement on hostile Wi-Fi and on an ISP that logs. It is not anonymity, and it is worth being clear with yourself about which one you wanted.
Keeping it running
WireGuard has no accounts and no session state, so there is not much to
maintain. Three things are worth doing: apply security updates to the server
the way you would any other machine; remove peers you no longer use with
wg set wg0 peer THEIR_KEY remove followed by
wg-quick save wg0, because a key you have forgotten about is a
key somebody else may still have; and keep a copy of
/etc/wireguard/wg0.conf somewhere safe, since it holds the server
key and every peer, and is the only file you would need to rebuild the
machine.