WG-Easy: Set Up a WireGuard VPN with a Web UI in 10 Minutes
There's a moment every self-hoster hits. You've got Grafana, a Postgres admin panel, maybe an internal dashboard or two, and none of them should ever face the public internet. So you start down the rabbit hole: reverse proxy, basic auth, IP allowlists, a Cloudflare tunnel, and somewhere in there you've built a security posture out of duct tape.
The cleaner answer is a VPN. And WireGuard is the right VPN, except configuring it by hand means editing .conf files, generating keypairs on both ends, and explaining to a colleague over Slack why their phone won't connect.
WG-Easy is WireGuard with a web UI on top. You click "new client," it hands you a QR code, and the phone is on the VPN. That's the whole pitch, and it's a good one.
Read This Before You Start
WG-Easy v15 is a full rewrite, and I want to put this up front because it's where people lose an afternoon.
If you're running v14, you cannot upgrade in place. The data model, the API and the configuration approach all changed. Every WG_* environment variable you carefully set in v14 is ignored in v15. Configuration now happens through a setup wizard in the browser on first boot, including the host address and the admin password that used to be WG_HOST and PASSWORD_HASH.
The migration path is: hit Backup in the v14 UI to download wg0.json, run docker compose down (not stop, or you'll leave inconsistent state behind), start v15 fresh, and upload that file when the wizard asks whether you have an existing config.
Also worth knowing: v15 dropped ARMv6, and v15.2.0 dropped ARMv7. If your VPN box is an older Raspberry Pi, check before you pull.
The Compose File
Here's the current setup, straight from upstream:
volumes:
etc_wireguard:
services:
wg-easy:
image: ghcr.io/wg-easy/wg-easy:15
container_name: wg-easy
networks:
wg:
ipv4_address: 10.42.42.42
ipv6_address: fdcc:ad94:bacf:61a3::2a
volumes:
- etc_wireguard:/etc/wireguard
- /lib/modules:/lib/modules:ro
ports:
- "51820:51820/udp"
- "51821:51821/tcp"
restart: unless-stopped
cap_add:
- NET_ADMIN
- SYS_MODULE
# - NET_RAW # uncomment if using Podman
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
- net.ipv6.conf.all.disable_ipv6=0
- net.ipv6.conf.all.forwarding=1
- net.ipv6.conf.default.forwarding=1
networks:
wg:
driver: bridge
enable_ipv6: true
ipam:
driver: default
config:
- subnet: 10.42.42.0/24
- subnet: fdcc:ad94:bacf:61a3::/64
docker compose up -d, then open port 51821 in your browser.
Two ports, two jobs:
| Port | Protocol | What it does |
|---|---|---|
| 51820 | UDP | The actual WireGuard tunnel. Must be reachable from the internet. |
| 51821 | TCP | The admin web UI. Should not be reachable from the internet. |
That second row is the part people get wrong. Once the VPN is up, restrict the UI to the VPN subnet or put it behind your reverse proxy with real auth. An admin panel that mints VPN credentials is not something you leave open on a public IP.
The Capabilities Are Not Optional
NET_ADMIN lets the container manage network interfaces and routing. SYS_MODULE plus the read-only /lib/modules mount let it load the WireGuard kernel module if your host hasn't already. The sysctls block turns on IP forwarding, which is what actually makes traffic move from the tunnel to the rest of your network.
Strip any of those out because they look scary and you get a container that starts cleanly, shows a healthy UI, completes a handshake, and passes exactly zero packets. Which is a genuinely annoying way to spend an evening.
Adding Clients
The wizard walks you through the admin account and the public host address. After that, adding a client is one button. WG-Easy generates the keypair, assigns an IP from the pool, and renders a QR code.
On a phone: install the official WireGuard app, scan the code, toggle on. On a laptop: download the .conf and import it. That's it, and it's the reason this tool exists. Handing a non-technical colleague a QR code is a completely different experience from walking them through key generation.
Beyond the basics, v15 ships two-factor auth on the admin panel, a Prometheus metrics endpoint, a proper REST API, and a CLI.
Troubleshooting
Handshake succeeds but no traffic flows. Almost always IP forwarding. Confirm the sysctls block is present and check sysctl net.ipv4.ip_forward on the host reads 1.
Client never connects at all. WireGuard is UDP, and plenty of corporate and hotel networks block outbound UDP on non-standard ports. Test from mobile data first to isolate it. If UDP is genuinely blocked, no amount of config will fix it.
Container starts, UI works, tunnel doesn't. Check the WireGuard module loaded: lsmod | grep wireguard on the host. Any kernel from 5.6 onward has it built in.
Running Podman instead of Docker. Uncomment NET_RAW in cap_add. Podman's default capability set is narrower.
Upgraded from v14 and everything is gone. Your old config wasn't migrated, because it can't be. Restore the wg0.json backup through the setup wizard.
Running It Somewhere That Isn't Your Laptop
A VPN gateway needs a stable public IP and needs to actually stay up, which rules out the box under your desk. The resource footprint is small since WireGuard runs in kernel space, so a modest VM handles a team comfortably.
If you'd rather not maintain the host yourself, WG-Easy on Elestio starts at $11/month fully managed, with SSL, automated backups, monitoring and updates handled for you, across Hetzner, DigitalOcean, Vultr, Linode, Scaleway, Netcup, AWS or your own VM.
Either way, the payoff is the same: your internal services stop needing a public door, and onboarding someone becomes a QR code instead of a support ticket.
Thanks for reading ❤️ See you in the next one 👋