Exposing SSH or dashboards directly to the internet is unnecessary risk for small deployments. WireGuard gives a cleaner and safer remote access model.

I keep the Pi behind NAT, open only the VPN endpoint on a gateway, and route internal management traffic through private tunnel addresses.

Keys are rotated on a schedule, and each device gets its own peer profile. That makes revocation simple when a node is retired.

Combined with firewall rules that allow admin ports only on the VPN interface, this approach has been low-maintenance and resilient.

Generating keys

WireGuard uses static keypairs per peer. I generate them on the device they belong to and never move private keys around. I set a strict umask first so the private key is not readable by other users while it is being written.

umask 077
wg genkey | tee privatekey | wg pubkey > publickey

privatekey stays on the box; only the contents of publickey get copied into the other side's config. I do this once per device so every node has its own identity.

Server (gateway) config

This lives on the gateway Pi as /etc/wireguard/wg0.conf. I keep the tunnel in a dedicated private range and scope each peer's AllowedIPs to a single tunnel address. Note that AllowedIPs here is a routing/ACL rule: only the listed source addresses are accepted from that peer.

# /etc/wireguard/wg0.conf (server)
[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = <SERVER_PRIVATE_KEY>

# Laptop
[Peer]
PublicKey = <LAPTOP_PUBLIC_KEY>
AllowedIPs = 10.10.0.2/32

# Phone
[Peer]
PublicKey = <PHONE_PUBLIC_KEY>
AllowedIPs = 10.10.0.3/32

I deliberately do not route 0.0.0.0/0 through the tunnel. This is split-tunnel admin access: clients only reach the 10.10.0.0/24 management network, not the whole internet via the Pi. That keeps the blast radius small and avoids turning the Pi into an accidental default gateway.

Client (peer) config

On the laptop, /etc/wireguard/wg0.conf mirrors the server but points AllowedIPs at just the tunnel subnet, which is what makes this split-tunnel rather than full-tunnel.

# /etc/wireguard/wg0.conf (client)
[Interface]
Address = 10.10.0.2/32
PrivateKey = <LAPTOP_PRIVATE_KEY>

[Peer]
PublicKey = <SERVER_PUBLIC_KEY>
Endpoint = vpn.example.net:51820
AllowedIPs = 10.10.0.0/24
PersistentKeepalive = 25

PersistentKeepalive matters when the client sits behind NAT; without it the mapping expires and inbound admin sessions stall.

Bringing the interface up

For a quick test I bring it up by hand, then I enable it as a service so it survives reboots.

sudo wg-quick up wg0
sudo systemctl enable --now wg-quick@wg0

wg show confirms the handshake and the latest transfer counters, which is usually the first thing I check when a peer cannot connect.

Firewall: expose only the tunnel port, admin only on wg0

The only thing that should be reachable from the WAN is the WireGuard UDP port. SSH and any dashboards are bound to the wg0 interface so they are unreachable from the public internet. Here is the nftables version I use on the gateway:

#!/usr/sbin/nft -f
flush ruleset

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

    iif lo accept
    ct state established,related accept

    # WireGuard endpoint is the only WAN-facing service
    udp dport 51820 accept

    # SSH / admin only over the tunnel interface
    iifname "wg0" tcp dport 22 accept

    counter drop
  }
}

If you prefer ufw, the equivalent is allowing the port globally but restricting admin to the interface:

sudo ufw allow 51820/udp
sudo ufw allow in on wg0 to any port 22 proto tcp
sudo ufw --force enable

Either way, the rule I care about is that port 22 is never accepted on the WAN interface — only on wg0.

Revoking and rotating a peer

Because every device has its own keypair, retiring one is just removing its [Peer] block from wg0.conf and reloading without dropping the other tunnels:

sudo wg syncconf wg0 <(wg-quick strip wg0)

For a full rotation I generate a fresh keypair on the device (same umask 077 step), replace the old PublicKey in the server config, and syncconf again. wg syncconf applies peer changes in place, so live sessions on unaffected peers stay up. When I permanently retire a node I also delete its privatekey/publickey files on the device itself.

Sources