A VPS with SSH exposed on its public IP is never quiet. Bots scan the IPv4 space continuously, looking for port 22, and the service stays visible the moment it is bound to that address. WireGuard fixes that at the root: SSH moves onto a private interface, so the public port effectively stops existing.
Initial VPS bootstrap
The public IP was only ever meant to be a doorway.
At the beginning, that doorway does what it should. You connect, update the machine, install the tools you need, and move fast enough to get to something usable.
Nothing special there. A fresh VPS always starts by being public. The real decision is when to stop treating it that way.
In this case, the goal was simple: keep SSH available, but stop treating the public address as the place where administration lives. Move the working surface behind WireGuard first, then make sure the old path is no longer the one that matters.
I started by connecting to the machine over SSH.
ssh root@VPS-PUBLIC-IP
From there, I updated the system, installed WireGuard and UFW, and opened only the ports needed for the bootstrap period. SSH had to stay reachable long enough to finish the setup, and WireGuard needed its own UDP port.
apt update && apt upgrade -y
apt install wireguard ufw -y
ufw allow 22/tcp
ufw allow 51820/udp
ufw enable
UFW was not the final lock on the door. It was the first perimeter. It made the machine usable without pretending it was already private.
Then I turned on IP forwarding and made it persistent. That was the quiet signal that this would not just be a point-to-point tunnel, but a route the VPS could actually use.
sysctl -w net.ipv4.ip_forward=1
echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf
WireGuard tunnel setup
The real setup lives in the tunnel.
On the server, I generated a WireGuard keypair and gave it a proper place to live. The private key went into /etc/wireguard/server_private.key, and the public key into /etc/wireguard/server_public.key. Then I locked them down with chmod 600, because the tunnel may be the thing you talk about, but the secret material is what makes it trustworthy.
wg genkey | tee /etc/wireguard/server_private.key | wg pubkey > /etc/wireguard/server_public.key
chmod 600 /etc/wireguard/server_private.key
chmod 600 /etc/wireguard/wg0.conf
The server config itself is compact, but every line has a job. It gives the VPS 10.0.0.1/24, listens on 51820, points to the server private key, and adds NAT and forwarding rules so traffic from the tunnel can leave through the normal network interface without turning the box into a dead-end.
[Interface]
Address = 10.0.0.1/24
ListenPort = 51820
PrivateKey = SERVER_PRIVATE_KEY
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT
PreDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
PreDown = iptables -D FORWARD -i wg0 -j ACCEPT
[Peer]
PublicKey = CLIENT_PUBLIC_KEY
AllowedIPs = 10.0.0.2/32
The client followed the same pattern, just on the machine I control. I generated another keypair and copied out the public half.
wg genkey | tee ~/client_private.key | wg pubkey > ~/client_public.key
cat ~/client_public.key
Then I wrote the matching client config in its own file.
[Interface]
Address = 10.0.0.2/32
PrivateKey = CLIENT_PRIVATE_KEY
[Peer]
PublicKey = SERVER_PUBLIC_KEY
Endpoint = VPS-PUBLIC-IP:51820
AllowedIPs = 10.0.0.0/24
PersistentKeepalive = 25
There was one practical detail before bringing the tunnel up. If an older WireGuard interface was already using the same 10.0.0.0/24 space, I had to bring it down first. Clean addresses make the rest of the story much easier to follow.
sudo wg-quick down wg0
Then I started the server interface and brought the client up against the saved config.
systemctl enable wg-quick@wg0
systemctl start wg-quick@wg0
systemctl status wg-quick@wg0
sudo wg-quick up ~/wg-client.conf
The first meaningful checkpoint is not SSH. It is the tunnel itself.
ping -c 4 10.0.0.1
If that works, the private path exists. If it does not, nothing downstream matters yet.
Restricting SSH to the tunnel
Once the ping returned cleanly, the focus shifted from transport to access control. This is where the setup gets interesting.
The public SSH port does not disappear because UFW alone somehow "hides" it. UFW is part of the initial perimeter, yes, but the decisive change is elsewhere: ssh.socket is reconfigured to listen only on 10.0.0.1:22. That matters because a port is only reachable if something is actually listening on that address. If SSH is bound to the WireGuard interface, the public IP can still exist, but port 22 on that public address is no longer the place where SSH answers.
Before moving SSH off the public interface, UFW needs one explicit rule. Even after SSH is bound to the WireGuard address, UFW still controls what reaches that interface. Without this rule, connections time out silently regardless of what the socket is doing.
ufw allow in on wg0 to any port 22
The concrete change is small, but it is the whole point. I opened a systemd override and replaced the default listen address with the tunnel address instead:
[Socket]
ListenStream=
ListenStream=10.0.0.1:22
That first empty ListenStream= line matters. It clears the default binding before adding the new one. Without that, the socket would keep listening where it always had.
After the override, systemctl daemon-reload makes systemd notice the change, and systemctl restart ssh.socket applies it.
systemctl daemon-reload
systemctl restart ssh.socket
Then I verified the result the obvious way: by checking which address SSH was actually listening on.
ss -tlnp | grep 22
At that point, SSH is no longer a public service in the usual sense. It is a service available only once the WireGuard interface exists.
After that, I removed the old host key for the tunnel address. That cleared the stale SSH fingerprint so I could reconnect cleanly over the private network.
ssh-keygen -R 10.0.0.1
Then I connected over the tunnel itself.
ssh root@10.0.0.1
The last check was deliberately unsentimental: try the public IP again and expect it to fail. Whether it refuses immediately or times out, the point is the same. The machine is still reachable, but not in the way it was before.
ssh -o ConnectTimeout=5 root@VPS-PUBLIC-IP
What changes in practice
What changes operationally is more than the test result.
A public VPS is always exposed to noise: scans, probes, brute force, random traffic that arrives because the address is there. Moving SSH behind WireGuard changes the daily posture of the machine. Public access becomes a bootstrap step, not a standing assumption. Recovery still exists, but the normal path is now private and intentional. That makes the server easier to reason about, easier to lock down, and less dependent on trust in the public network.
It also changes how I think about maintenance. I am no longer "logging into the server from the internet." I am joining a private network first, then administering the machine from inside it. That is a small shift on paper and a large one in practice.
WireGuard is not the main feature here. It is the boundary that lets everything else feel quieter.







Top comments (0)