How to Deploy wg-easy on a VPS
Updated Jul 2026
verified on Ubuntu 26.04 · Jul 2026Self-host wg-easy on your own VPS — a WireGuard VPN server with a web UI for managing peers, replacing paid VPN services with one Docker container you fully control.
- A VPS with 1 vCPU / 512 MB RAM or more — wg-easy itself needs well under 256 MB, since WireGuard's crypto runs in the Linux kernel, not the app
- A fresh Ubuntu 26.04 server with root/sudo SSH access, Docker + Compose installed
- A domain (or an `<ip>.sslip.io` hostname) pointed at the server, for the web UI's TLS certificate — the VPN tunnel itself doesn't need DNS
What wg-easy is
wg-easy is a WireGuard VPN server bundled with a web UI —
one Docker container that runs the actual WireGuard tunnel and gives you a
browser-based dashboard for adding, disabling, and QR-coding client configs,
instead of hand-editing wg0.conf and juggling keypairs on the command line.
It's AGPL-3.0-licensed and has nothing phoning home: every peer, key, and
config lives on your box.
The appeal is the same trade every self-hosted tool makes: a commercial WireGuard VPN (or a mesh product like Tailscale) bills per seat or per device, and the moment you add a few laptops, phones, and a home server the bill compounds. A single cheap VPS running wg-easy costs the same flat monthly fee whether you connect two devices or twenty, and you're not trusting a third party's infrastructure to sit in the middle of your traffic. The trade-off going in is operational: no managed mesh networking, no automatic NAT traversal between peers, no built-in ACLs — wg-easy gives you a classic hub-and-spoke WireGuard VPN with an easy control panel, and that's the whole feature set. If you want peer-to-peer mesh routing without a central relay, that's Headscale or Netbird territory instead.
WireGuard itself has been in the mainline Linux kernel since 5.6, so the actual tunnel runs at kernel speed — the "app" you're deploying is really just a management layer on top of a kernel feature your VPS already has.
Server sizing — the smallest box is enough
wg-easy is one of the lightest things you'll self-host. The web UI is a small Node/Nuxt process, and the tunnel itself is handled by the kernel's WireGuard module, not user-space code — so the container's own footprint sits well under 256 MB RAM at idle, and CPU use scales with how much traffic actually flows through the tunnel, not with the number of configured peers. 1 vCPU and 512 MB–1 GB RAM is comfortable for a personal or small-team VPN; the cheapest tier from any provider works.
The one dimension that matters more than CPU or RAM is network throughput and location. Every byte your devices send over the VPN round-trips through this box, so pick a region close to where you and your devices actually are, and don't undersize the VPS's network allowance if you plan to route a lot of traffic (e.g. using it as a full-tunnel exit node) rather than just reaching a handful of internal services.
Prepare the server
This guide assumes you've already worked through
Docker & Compose on Ubuntu — a fresh
Ubuntu 26.04 box, a non-root deploy user, Docker Engine and the Compose
plugin installed, and ufw allowing SSH plus 80 and 443. If you haven't,
do that first.
wg-easy needs one more port that the base guide doesn't open: 51820/udp, the actual WireGuard tunnel. Unlike the web UI (which sits behind Caddy on 443), the tunnel port has to be reachable directly — a reverse proxy can't sit in front of a UDP protocol the way it can for HTTP:
sudo ufw allow 51820/udp
Create a working directory for the stack:
mkdir ~/wg-easy && cd ~/wg-easy
Install wg-easy
wg-easy runs as a single container plus, in this setup, Caddy in front of
its web UI for HTTPS — two services, one compose file. The container needs
two Linux capabilities to manage the tunnel and load the WireGuard kernel
module (NET_ADMIN, SYS_MODULE), and two sysctls so it can route and NAT
traffic through the box.
First, generate the admin password and write it to a .env file the
compose stack will read:
cat > .env <<EOF
INIT_PASSWORD=$(openssl rand -base64 24)
EOF
chmod 600 .env
Save that password somewhere safe right now — it's the only credential that logs you into the dashboard, and it isn't shown again after this step.
Now create the compose file:
cat > docker-compose.yml <<'YAML'
services:
wg-easy:
image: ghcr.io/wg-easy/wg-easy:15
container_name: wg-easy
restart: unless-stopped
cap_add:
- NET_ADMIN
- SYS_MODULE
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
ports:
- "51820:51820/udp"
volumes:
- etc_wireguard:/etc/wireguard
- /lib/modules:/lib/modules:ro
environment:
INIT_ENABLED: "true"
INIT_USERNAME: admin
INIT_PASSWORD: ${INIT_PASSWORD}
INIT_HOST: wg.203.0.113.10.sslip.io
INIT_PORT: "51820"
INIT_DNS: "1.1.1.1,8.8.8.8"
caddy:
image: caddy:2
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
volumes:
etc_wireguard:
caddy_data:
caddy_config:
YAML
A few things worth understanding before you bring it up:
INIT_ENABLED: "true"is what makes this a non-interactive install. Without it, wg-easy shows a first-run setup wizard in the browser; with it, wg-easy creates the admin account and the WireGuard interface from theINIT_*env vars on first boot, so there's nothing to click through.INIT_USERNAME,INIT_PASSWORD,INIT_HOST, andINIT_PORTare one group and have to be set together.INIT_HOSTis the address WireGuard clients connect to — the public hostname or IP baked into every client config. Replacewg.203.0.113.10.sslip.iowith your own domain, or your server's real public IP wrapped in the same<ip>.sslip.iopattern (see the next section). It does not need to be reachable over HTTP — it's the WG tunnel endpoint, resolved once by the client, not proxied by Caddy./lib/modules:/lib/modules:rolets the container load thewireguardkernel module if the host doesn't already have it loaded. On a modern Ubuntu kernel it's usually built in already, but the mount costs nothing and avoids a boot failure if it isn't.- No
caddyenv var points at wg-easy — Caddy and wg-easy share Compose's default network for this project, so the Caddyfile below reaches the web UI by service name (wg-easy:51821), the same pattern used in Automatic HTTPS with Caddy. Note that the web UI port (51821/tcp) is not published in this compose file at all — only Caddy can reach it, and only Caddy is exposed to the internet on 80/443.
Now the Caddyfile:
cat > Caddyfile <<'CADDY'
wg.203.0.113.10.sslip.io {
reverse_proxy wg-easy:51821
}
CADDY
Bring the stack up and confirm wg-easy started cleanly:
docker compose up -d
docker compose logs --tail 40 wg-easy
A healthy first boot logs the INIT_* values being applied and the
WireGuard interface coming up — no errors about missing capabilities or a
failed iptables call. To watch it continuously instead of a fixed tail:
docker compose logs -f wg-easy
HTTPS + domain
The dashboard needs a real hostname and HTTPS — WireGuard clients connect
straight to INIT_HOST on UDP 51820 with no browser involved, but the
web UI you use to manage peers is an ordinary HTTPS site behind Caddy,
same as every other app on this site.
No real domain? Use sslip.io. <ip>.sslip.io is a public DNS service
that resolves any dotted-IP subdomain straight to that IP — 1.2.3.4.sslip.io
resolves to 1.2.3.4, with no records to create. That's what
wg.203.0.113.10.sslip.io in the config above is: replace
203.0.113.10 with your server's actual public IPv4 address everywhere in
this guide (the compose file, the Caddyfile, and every command below), and
it resolves immediately — Let's Encrypt accepts it like any other domain.
Confirm it resolves before expecting a certificate:
dig +short wg.203.0.113.10.sslip.io
# should print your server's IP
Caddy handles the rest automatically — see Automatic HTTPS with Caddy for how the ACME challenge and renewal work. Once DNS resolves, hitting the domain triggers certificate issuance on first request:
# The first HTTPS request makes Caddy fetch a Let's Encrypt certificate,
# which can take up to a minute. Poll until the handshake succeeds:
for i in $(seq 1 40); do
code=$(curl -sS -o /dev/null -w '%{http_code}' https://wg.203.0.113.10.sslip.io 2>/dev/null || echo 000)
[ "$code" != "000" ] && { echo "web UI up: HTTP $code"; break; }
sleep 5
done
curl -sSI https://wg.203.0.113.10.sslip.io | head -1
# HTTP/2 200 (or a redirect into the app) once the cert is issued
If this hangs or times out from outside the box, check your cloud
provider's network firewall (not just ufw) allows inbound 80 and 443 —
see the Caddy guide's troubleshooting section for the exact symptom.
First-run setup
Because INIT_ENABLED did the setup for you, there's no signup screen to
click through. Open https://wg.203.0.113.10.sslip.io and log in with
INIT_USERNAME (admin) and the password from .env.
Confirm the WireGuard interface actually came up inside the container:
docker compose ps
docker compose exec wg-easy wg show
A working interface prints its listening port and public key; an empty or
missing wg0 device means the capabilities or sysctls didn't apply — see
Troubleshooting.
From the dashboard, add your first client:
- Click New Client, give it a name (e.g. "laptop"), and save.
- Download its
.conffile, or scan the QR code with the WireGuard mobile app — either imports the tunnel directly. - Activate the tunnel on that device and confirm it shows Connected in wg-easy's client list, with a recent handshake timestamp.
- Try reaching something only the VPS can reach (or just check your device's public IP changed) to confirm traffic is actually routing through the tunnel, not just establishing a handshake.
A recent handshake in the UI is the real signal everything works end to end — a client can show as "added" without ever having connected.
Rotate the password once you're in. INIT_PASSWORD sat in a .env
file in plaintext to get you bootstrapped; the docs recommend removing the
INIT_* block from docker-compose.yml (or at least the password) once
the account exists, since those vars carry no complexity check and
restarting the stack doesn't re-run them against an existing account
anyway. Change the password from the dashboard's account settings instead.
Backups
Everything that matters — the admin account, every client's keys and
config, and the WireGuard interface state — lives in the etc_wireguard
volume mounted at /etc/wireguard. wg-easy's image doesn't ship tar, so
back it up from a disposable helper container instead of docker compose exec:
VOLUME=$(docker volume ls -q | grep etc_wireguard)
docker run --rm -v "${VOLUME}:/data" -v "$(pwd):/backup" alpine \
tar czf /backup/wg-easy-backup-$(date +%F).tar.gz -C /data .
ls -lh wg-easy-backup-*.tar.gz
Copy that archive off the box on a schedule — a backup that only exists on
the VPS it's backing up dies with the VPS. If you ever need to restore, spin
up a fresh etc_wireguard volume, untar the archive into it, and start the
stack pointed at the same INIT_HOST; existing client configs keep working
because the keys didn't change.
Upgrades
Pull the newer image and recreate the container:
docker compose pull
docker compose up -d
wg-easy's config format has changed across major versions before (v14 to
v15 was a full rewrite with a different config file). Read the release
notes before jumping a major version, and back up etc_wireguard first —
the same backup command as above, run before you pull.
Troubleshooting
wg show prints nothing, or the container exits immediately. Almost
always a missing capability. Confirm cap_add: [NET_ADMIN, SYS_MODULE] is
actually in the compose file you brought up (not just the one you meant to
paste), then recreate:
docker compose up -d --force-recreate wg-easy
Clients never show a handshake. The INIT_HOST baked into their config
doesn't match a reachable address, or 51820/udp isn't actually open. Check
both:
sudo ufw status | grep 51820
If ufw looks right but a client still can't connect, check your cloud
provider's network-level firewall too — the same two-layer trap covered in
the Caddy guide's troubleshooting section applies to UDP just as much as
TCP 80/443.
Web UI unreachable but the tunnel works. These are independent —
51820/udp (the tunnel) and 51821/tcp behind Caddy (the dashboard) are
separate paths. If clients connect fine but https://wg.… doesn't load,
the problem is in the Caddy/DNS chain, not wg-easy itself; work through the
HTTPS + domain section above.
Changed INIT_HOST and now nothing connects. Existing client configs
still point at the old host — INIT_HOST only affects new clients
generated after the change. Re-download or re-scan the QR code for any
client that needs to move to the new address.
Verification + next steps
You're done when: docker compose ps shows both containers Up, the
dashboard loads over HTTPS on your domain and logs in with your rotated
password, wg show inside the container lists your interface, a client
shows a recent handshake after connecting, and a backup archive of
etc_wireguard exists off the box.
From here, add the rest of your devices as clients, and consider whether you want a full-tunnel setup (route all client traffic through the VPS) or a split-tunnel one (only route specific subnets) — that's a per-client setting in the dashboard, not a server-wide one. If you outgrow a single hub-and-spoke VPN — you want peers to reach each other directly, or you need SSO-gated access to internal apps rather than a flat VPN — that's where Headscale, Netbird, or Pangolin pick up. A Hetzner CX22 or the smallest Kamatera tier is more than enough to run this indefinitely; see Best VPS for self-hosting for the ranked picks.