Skip to content

Command Palette

Search for a command to run...

How to Deploy wg-easy on a VPS

Updated Jul 2026

verified on Ubuntu 26.04 · Jul 2026

Self-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.

Before you start
  • 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 the INIT_* env vars on first boot, so there's nothing to click through. INIT_USERNAME, INIT_PASSWORD, INIT_HOST, and INIT_PORT are one group and have to be set together.
  • INIT_HOST is the address WireGuard clients connect to — the public hostname or IP baked into every client config. Replace wg.203.0.113.10.sslip.io with your own domain, or your server's real public IP wrapped in the same <ip>.sslip.io pattern (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:ro lets the container load the wireguard kernel 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 caddy env 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:

  1. Click New Client, give it a name (e.g. "laptop"), and save.
  2. Download its .conf file, or scan the QR code with the WireGuard mobile app — either imports the tunnel directly.
  3. Activate the tunnel on that device and confirm it shows Connected in wg-easy's client list, with a recent handshake timestamp.
  4. 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.

Next steps

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.