Tunl

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

FieldValue
Server public keyThe line cat /etc/wireguard/server.pub printed.
Endpointvpn.example.net:51820. A name, so Tunl can re-resolve it.
Your address in the tunnel10.8.0.2/24, fd42:8::2/64. Tunl narrows both to single hosts in the peer block it shows you.
DNSSee 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 seeWhere to look
No handshake at allUDP 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 loadsForwarding 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'tDNS. 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 foreverMTU. 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 idleA 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.