I run WireGuard Easy on the small Hetzner VPS that fronts every private service I touch: 2FAuth, Uptime Kuma, the internal admin URL of my password manager. It’s been my default self-hosted VPN for client work for the better part of two years. wg-easy is the fastest path I know from a fresh Debian box to a working WireGuard tunnel with a usable admin UI, and that combination is what makes it the right tool for one-admin networks.
This post is the actual deployment I ship. The Compose file, the firewall rules, the bind-port detail that catches most people the first time, and the parts of the stack I deliberately leave out. About fifteen minutes from a hardened server to a working tunnel with the first peer issued.
If you’ve never deployed wg-easy, copy the Compose snippet below verbatim, change the two placeholder values, and read the admin-UI exposure section before you bring the container up. That section is where most self-hosted VPN deployments quietly turn into security incidents.
Why wg-easy beats hand-rolled WireGuard for most setups
WireGuard the protocol is famously elegant. Roughly 4,000 lines of kernel code, audited, fast, and the right primitive for any modern VPN. The cost of that elegance is that the user-space tooling is a bag of CLI commands and config files. Issuing a new peer means generating a keypair, adding a [Peer] block to wg0.conf, restarting the interface, and either generating a QR code by hand or shipping the config file to the client.
I’m fine with that on my own gateway boxes. I’m not fine with it when I’m issuing tunnels to a client’s developer who needs access to a staging server tomorrow. wg-easy is the layer that turns peer management into a web form: type a name, click a button, scan the QR code on a phone, done.
The trade-off is that wg-easy is a deliberately small project. It’s a peer manager and a UI. It does not do LDAP, RBAC, audit logs, or split-tunnel policies. If you need those, you’re looking at headscale, a commercial WireGuard control plane, or a fuller-featured platform like Mistborn. For a one-admin tunnel with up to a few dozen peers, the minimal scope is exactly the feature.
Prerequisites
A fresh VPS with a public IPv4 address, at minimum 512MB RAM, and either x86 or ARM64. Debian 12 is what I run it on. wg-easy works fine on Ubuntu LTS too; the Docker engine layer is the same.
You also need a hardened server before anything VPN-related lands on it. SSH keys, no root login, UFW with deny-by-default. If you haven’t done that yet, my Linux server security fundamentals post is the baseline I run on every fresh box. A VPN server with sloppy SSH is the worst kind of false confidence. You’ve made yourself a more attractive target without raising the bar.
DNS is optional. wg-easy works fine with WG_HOST set to the raw public IP, and that’s actually what I recommend for this stack. There’s no benefit to putting a domain in front of the VPN endpoint, and you remove one piece of dependency that can break.
The wg-easy deployment stack
Here’s the Compose file I bring up on every wg-easy host. Two values change before you start the container, and the rest stays the same.
version: "3"
services:
wg-easy:
container_name: wg-easy
image: ghcr.io/wg-easy/wg-easy
environment:
- LANG=en
- WG_HOST=203.0.113.10 # your server's public IPv4
- PASSWORD=replace-with-strong-passphrase
volumes:
- /srv/docker/wg-easy:/etc/wireguard
ports:
- "51820:51820/udp" # WireGuard tunnel, public
- "127.0.0.1:51821:51821/tcp" # admin UI, localhost only
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
- net.ipv4.ip_forward=1
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=true"
The two values that must change before you bring it up: WG_HOST and PASSWORD. WG_HOST is what wg-easy bakes into every peer config it generates, so the clients know where to dial. Use the public IPv4 of the host. PASSWORD protects the admin UI; generate a long passphrase from your password manager (seven words plus a number is the floor, not the ceiling).
The detail that catches most people the first time is the admin-UI port binding. The original wg-easy README shows 51821:51821/tcp, which binds to all interfaces and exposes the admin UI publicly. That’s not what you want. The version above binds it to 127.0.0.1 only, so the only way to reach the UI is from localhost. That means you’re either SSH-tunneling into the VPS or, after the first peer is issued, connecting over WireGuard and reaching it via the tunnel IP (typically 10.8.0.1:51821).
Drop the file at /srv/docker/wg-easy/docker-compose.yml, make sure the parent directory exists for the volume, and bring it up:
mkdir -p /srv/docker/wg-easy
docker compose -f /srv/docker/wg-easy/docker-compose.yml up -d
You also need WireGuard kernel forwarding enabled at the OS level. On Debian 12 it’s already on if you’ve installed the standard kernel; the sysctls inside the container handle the container-level side. If you’ve stripped down the kernel, install linux-image-amd64 or the equivalent for your arch.
Installing Docker engine on the host
Install Docker from the official Docker repository, not from apt’s default package or from a one-line curl | sh script you found in someone’s blog post. The official repository is the only source I trust to keep up with security updates. The canonical instructions are at the official Docker installation guide for Debian.
If you’re rebuilding a box quickly and you accept the risk, the upstream convenience script does work. It pulls from the same official repository under the hood:
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
For production hosts I prefer the manual repository setup because it’s auditable. For a dev box being rebuilt for the third time today, the convenience script is fine.
First-login setup and issuing the first peer
SSH into the VPS with port forwarding for the admin UI:
ssh -L 51821:localhost:51821 user@203.0.113.10
Browse to http://localhost:51821 on your laptop. You’ll get the wg-easy login screen, password is the value from PASSWORD. Click “New”, give the peer a name like laptop or phone-simon, and the UI generates a config file plus a QR code.
On your phone, install the official WireGuard app from the App Store or Play Store, hit the plus button, scan the QR code. On Linux or macOS, download the config file and import it with wg-quick up <file>. Either way, the peer is up within a couple of seconds, and you’ll see the handshake timestamp tick over green in the wg-easy UI.
Once the first peer is connected, you don’t need the SSH tunnel anymore. The admin UI is reachable at 10.8.0.1:51821 over the tunnel itself, which is the right pattern: VPN access to the VPN admin.
Firewall rules that match the threat model
UFW on the host should allow exactly two things from the public side:
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 51820/udp
ufw enable
That’s it. SSH for management (preferably from a known IP range), and the WireGuard UDP port for the tunnel. The admin UI is bound to localhost so UFW doesn’t need a rule for it. Anything else inbound is denied.
If your VPS provider has a separate cloud-firewall layer (Hetzner Cloud Firewalls, AWS security groups), mirror the same two-rule policy there. Defense in depth means two firewalls saying the same thing, not two firewalls disagreeing about what’s allowed.
What the WireGuard tunnel actually buys you
Once the tunnel is up, every device you’ve issued a peer config for has a private IP on the 10.8.0.0/24 network and can reach the VPS as 10.8.0.1. From there you can:
- Reach the wg-easy admin UI at
10.8.0.1:51821to manage peers. - Run private services on the VPS bound to
10.8.0.1instead of the public interface, so they’re reachable only over the tunnel. This is how I run 2FAuth, Uptime Kuma, and other admin URLs that have no business being on the public internet. - Route all client traffic through the VPN if you set
AllowedIPs = 0.0.0.0/0on the peer. Useful on coffee-shop wifi, less useful on a static office connection. - Reach other hosts on the same private network if the VPS is also routed into a wider VPC.
The pattern I recommend for client work is: VPN on its own small VPS, private services on a separate larger VPS, both on the same Hetzner private network. Compromise of the wg-easy box gets you onto the private network but doesn’t directly hand you the data servers. Compromise of a data server doesn’t expose the VPN. Two boxes, two blast radii, one admin.
What I don’t bother with on a wg-easy host
A few things I leave off the wg-easy box specifically:
- Reverse proxy in front of the admin UI. It’s bound to localhost and reached over the tunnel. A reverse proxy adds nothing here and complicates the rebuild.
- DNS-level ad-blocking on the same box. If you want Pi-hole, run it on a separate container and route VPN clients to it via WireGuard’s DNS field. Don’t pile services onto the VPN host.
- CrowdSec or fail2ban on port 51820/udp. WireGuard doesn’t reply to invalid handshakes, so there’s nothing for a brute-force scanner to grab onto. CrowdSec on port 22 still makes sense; on the WireGuard port it’s noise.
- Multiple admin users. wg-easy has one password, one admin. If you need delegated admin access across a team, you’ve outgrown wg-easy and you should look at Wirehole, Mistborn, or a control plane with proper RBAC.
The discipline is: VPN box is VPN-only. Anything else gets its own host.
Backups, sizing, and the boring operational stuff
The wg-easy data volume at /srv/docker/wg-easy (or wherever you mounted /etc/wireguard) holds the server’s private key, every peer’s public key, and the assigned IP allocations. Lose it and every existing peer config stops working, because the new server keypair won’t validate against what the clients have on disk.
Back up the volume daily. I use restic with a remote repository on Backblaze B2, but borg, rclone with a daily cron, or your VPS provider’s snapshot feature all work. The non-negotiable is off-site and encrypted at rest. Restoring from backup brings every existing peer back online without touching the client devices, which is the disaster-recovery property that matters.
Resource usage is trivial. My production wg-easy boxes run at well under 1% CPU and around 40MB of RAM with 12–15 active peers. A 1GB VPS is enough; the bottleneck is upstream bandwidth, not the host. If you’re routing every client’s full traffic through the VPN (rather than just internal services), size the VPS plan based on the bandwidth quota rather than the RAM.
Watchtower (which the com.centurylinklabs.watchtower.enable=true label hooks into) handles container updates automatically. wg-easy ships breaking-change releases occasionally; for production hosts I pin to a specific tag and update on a schedule rather than letting Watchtower auto-update, so I’m in control of when the migration happens.
Verifying the deployment before you trust it
Before you issue a peer to a client or use the tunnel for anything sensitive, run this checklist:
- From the laptop, the WireGuard handshake completes within 5 seconds. The wg-easy UI shows a recent timestamp under the peer.
- From the laptop,
curl http://10.8.0.1:51821returns the wg-easy login page (over the tunnel). - From a separate machine not on the VPN,
curl http://203.0.113.10:51821times out or refuses connection. If it returns the login page, your bind config is wrong. Fix it before issuing more peers. - Stop the wg-easy container, restart it, verify the existing peer reconnects automatically. (
docker compose down && docker compose up -d.) - Take a backup of the volume. Restore it to a different temporary directory. Spin up a second wg-easy container on a test VPS pointing at the restored volume, with the same WG_HOST and PASSWORD. Verify it shows the same peers. This is your disaster-recovery rehearsal.
Step 3 is the one most people skip. Do it from a phone on cellular data, completely off the home network, to confirm the admin UI is actually unreachable from the public internet.
Closing the loop
wg-easy has been my default VPN tool for client work since 2023, and it’s the one tool in my self-hosting stack I’ve never had to replace. The setup time is well under an hour including the security baseline, the recurring cost is whatever you’re paying for a small VPS, and the operational burden is one daily backup job plus an occasional Watchtower update review.
The thing wg-easy actually buys you, beyond the convenience UI, is permission to take the rest of your private services off the public internet. Once the tunnel is up, the 2FAuth vault, the monitoring dashboards, the Authentik admin URL: none of them need a public DNS record or a Let’s Encrypt cert anymore. They live on 10.8.0.0/24 and you reach them over the tunnel. That architectural shift is worth more than the UI convenience, and it’s the reason wg-easy ends up being the first container I deploy on every new private cloud.