VPN 📖 13 min read

WireGuard-Easy Deployment

wg-easy runs a WireGuard server and a web admin panel in one Docker container. This guide deploys wg-easy v15 with Docker Compose, puts the admin panel behind HTTPS, adds clients, and covers backups, updates and the problems you're most likely to hit.

What wg-easy Does

WireGuard itself is just a kernel interface plus a small config file per peer. Running it by hand means generating key pairs, picking a tunnel address for every device and copying configs around (that manual route is covered in WireGuard Cloud Deployment). wg-easy automates that bookkeeping behind a browser UI. The project README lists, among other things:

  • Create, edit, enable, disable and delete clients
  • A QR code per client for mobile apps, and a downloadable .conf file for desktops
  • Connection status and per-client transfer charts
  • Client expiry dates and one-time download links
  • Two-factor authentication and optional OIDC sign-in for the admin panel
  • Prometheus metrics and per-client firewall filtering (needs iptables on the host)

The trade-off is that you now run a web application with full control over your VPN. Most of this guide's security advice is about that admin panel, not about WireGuard.

v15 Is a Rewrite: Read This If You Used v14

Older wg-easy guides (including the earlier version of this page) configure everything with environment variables such as WG_HOST and a PASSWORD variable. Those belong to v14. According to the official v14 to v15 migration guide:

  • v15 is a complete rewrite and doesn't use the v14 environment variables. Most settings moved into the Admin Panel in the web UI.
  • If you reach the web UI over plain HTTP, you must set INSECURE to true.
  • armv6 and armv7 hosts can't move to v15. Supported architectures are x86_64 and arm64.
  • To migrate, download the wg0.json backup from v14 first, then upload it in the v15 setup wizard ("Do you have an existing setup?").

The image tags matter too. The project's Getting Started page says the latest tag still points to v14 and should be avoided, and recommends pinning the major version tag 15, which gets new features and fixes without breaking changes.

Prerequisites

  • A Linux host you control (a VPS or a machine at home) on x86_64 or arm64.
  • Docker Engine with the Compose plugin. If you don't have it yet, follow the Docker Installation Guide.
  • A public IP address or a DNS name that points to it. If your home connection has a changing IP, use a dynamic DNS name. If you're behind carrier-grade NAT, you can't accept inbound connections at all; a mesh VPN such as Tailscale works around that.
  • UDP port 51820 reachable from the internet: open it in the host firewall and in your cloud provider's firewall or security group, or forward it on your home router.
  • For the HTTPS setup below, a domain name for the admin panel and a reverse proxy. The example uses Nginx; the Nginx Web Server Setup guide covers installing it and getting a certificate.

Deploy with Docker Compose

The official Basic Installation tutorial keeps the files in /etc/docker/containers/wg-easy and downloads the project's compose file:

Terminal window with the commands from the section Deploy with Docker Compose
Download the official Compose file into its own directory.
sudo mkdir -p /etc/docker/containers/wg-easy
sudo curl -o /etc/docker/containers/wg-easy/docker-compose.yml https://raw.githubusercontent.com/wg-easy/wg-easy/master/docker-compose.yml
cd /etc/docker/containers/wg-easy

At the time of writing that file looks like this (comments trimmed). Review it before you start the container rather than trusting a copy on any third-party page, including this one:

volumes:
  etc_wireguard:

services:
  wg-easy:
    #environment:
    #  - PORT=51821
    #  - HOST=0.0.0.0
    #  - INSECURE=false
    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
    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

Versions: this page was written against wg-easy v15. The newest release on the wg-easy releases page when it was updated was v15.5.0. The compose file uses the major tag 15, so docker compose pull picks up 15.x releases. If you'd rather control every update, pin an exact tag such as ghcr.io/wg-easy/wg-easy:15.5.0 and change it after reading the release notes. There is no top-level version: key; the Compose specification treats it as obsolete.

What the pieces do:

  • 51820/udp is the WireGuard port your clients connect to. 51821/tcp is the web UI.
  • etc_wireguard is a named volume mounted at /etc/wireguard. It holds all of wg-easy's state, including the server and client keys. Back it up (see below).
  • NET_ADMIN and SYS_MODULE let the container create the WireGuard interface and load the kernel module. The read-only /lib/modules mount serves the same purpose.
  • The sysctls turn on IP forwarding inside the container so client traffic can be routed onward.

Optional environment variables

The optional configuration page documents only a handful of variables:

  • PORT (default 51821): TCP port of the web UI.
  • HOST (default 0.0.0.0): address the web UI binds to inside the container.
  • INSECURE (default false): allow the web UI over plain HTTP. Leave it off if you use a reverse proxy with TLS.
  • DISABLE_IPV6 and DISABLE_VERSION_CHECK (both default false).

For automated deployments there are also INIT_* variables (INIT_ENABLED, INIT_USERNAME, INIT_PASSWORD, INIT_HOST, INIT_PORT, INIT_DNS and others) described on the unattended setup page. They're only read on the very first start, some must be set together, and the docs recommend removing them afterwards so the password isn't left in your compose file.

Pick How You'll Reach the Admin Panel

Since v15 the session cookie is marked Secure unless INSECURE is true. Browsers don't send such cookies over plain HTTP, so if you open http://SERVER_IP:51821 with the default settings, the login page tells you that you can't log in over an insecure connection. You have two options.

Option A (recommended): HTTPS through a reverse proxy

The wg-easy docs provide Caddy and Traefik examples. If Nginx already runs on the host, publish the UI port on the loopback address only, so nothing outside the host can reach it directly. In docker-compose.yml change the UI port line to:

      - "127.0.0.1:51821:51821/tcp"

Then add a server block with TLS (certificate paths are placeholders; the Nginx guide shows how to get one with Certbot):

server {
    listen 443 ssl;
    server_name wg.example.com;

    ssl_certificate     /etc/letsencrypt/live/wg.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/wg.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:51821;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Run sudo nginx -t before reloading Nginx. Leave INSECURE unset: the browser talks HTTPS to Nginx, so the secure cookie works.

Option B: plain HTTP on a trusted network only

The docs' No Reverse Proxy page marks this as insecure and not recommended. If the server sits on your home LAN and the UI is never reachable from outside, uncomment the environment: block and set INSECURE=true. Then make sure port 51821/tcp is closed to the internet in every firewall in front of the host.

Start wg-easy and Run the Setup Wizard

Terminal window with the commands from the section Start wg-easy and Run the Setup Wizard
Start wg-easy and follow the log while you run the setup wizard.
cd /etc/docker/containers/wg-easy
sudo docker compose up -d
sudo docker compose logs -f wg-easy

Open the admin panel (your HTTPS name, or http://LAN_IP:51821 with Option B). On first visit the setup wizard asks you to:

  1. Create the admin account. Use a long password; the docs note that setup doesn't check password complexity.
  2. Say whether you have an existing setup. Choose yes only when you're restoring a v14 wg0.json backup.
  3. Enter the host and port clients will connect to. The host must be your public IP or a DNS name that resolves to it. A private address such as the Docker bridge IP or a LAN IP behind NAT produces client configs that can't connect from outside.

The Getting Started page warns to use docker compose up and down, not start and stop; otherwise the container isn't properly destroyed and can start in an inconsistent state.

Configure Defaults in the Admin Panel

Settings that v14 took from environment variables now live under Admin Panel:

  • Config: the public host and port written into client configs, plus the global Allowed IPs, DNS, MTU and persistent keepalive for clients. Changing host or port invalidates existing client configs, so settle these first.
  • Interface: the server-side listen port, the tunnel address range (CIDR), the host network device traffic is forwarded through, and the per-client firewall switch.
  • General: session timeout and the metrics endpoints.
  • Hooks: PreUp/PostUp/PreDown/PostDown commands.

If you run your own DNS filter such as Pi-hole or AdGuard Home, set its address as the client DNS here so VPN clients get filtering too. The DNS server must be reachable through the tunnel.

Add Clients

  1. On the Clients page, click New and give the client a name that identifies the device.
  2. Phones: install the official WireGuard app, choose to add a tunnel from a QR code, and scan the client's QR code in wg-easy.
  3. Desktops: install the WireGuard client from wireguard.com/install, download the client's .conf file and import it as a tunnel.

The QR code and the .conf file contain the client's private key. Treat them like a password: don't paste them into chat apps or leave them in a downloads folder.

Full Tunnel or Split Tunnel

A client's Allowed IPs decide which destinations it routes through the VPN. 0.0.0.0/0, ::/0 sends everything through the server (a full tunnel), which is what you want on untrusted Wi-Fi. If you only need to reach a home network, list just those ranges, for example 192.168.1.0/24, and everything else uses the device's normal connection. Set the default under Admin Panel, Config, or override it per client.

Allowed IPs only control routing on the client, and a user can edit them locally. The client guide points to the per-client firewall (Admin Panel, Interface) if you need to enforce which destinations a client may reach on the server side. The admin panel guide marks that feature as experimental, so test the rules before relying on them.

Harden the Admin Panel

WireGuard's protocol isn't the weak point here; the admin panel is, because anyone who logs in can create working VPN credentials.

  • Use HTTPS (Option A) and keep 51821/tcp off the public internet.
  • Turn on two-factor authentication for the admin account.
  • If you only manage the server from known places, restrict the proxy further, for example with Nginx allow/deny rules for your home IP and the VPN's own address range, or by only exposing the admin hostname inside the VPN.
  • Remove any INIT_* variables after the first start.

Monitoring

The Clients page shows which clients are connected and their transfer charts. For history and alerts, wg-easy can expose Prometheus metrics; enable them and set a bearer password under Admin Panel, General, then scrape the endpoint from Prometheus (see Prometheus Grafana Monitoring).

Back Up and Restore

Everything lives in the etc_wireguard volume. Compose prefixes volume names with the project name (the directory name by default), so check the real name first:

Terminal window with the commands from the section Back Up and Restore
Back up the data volume while the container is down, into a file only you can read.
sudo docker volume ls | grep etc_wireguard
sudo docker volume inspect --format '{{ .Mountpoint }}' wg-easy_etc_wireguard

For a consistent copy, stop the stack, archive the volume's directory, and start it again:

cd /etc/docker/containers/wg-easy
sudo docker compose down
BACKUP="$HOME/wg-easy-backup-$(date +%F).tar.gz"
install -m 600 /dev/null "$BACKUP"
sudo tar -czf "$BACKUP" -C "$(sudo docker volume inspect --format '{{ .Mountpoint }}' wg-easy_etc_wireguard)" .
sudo docker compose up -d

The archive contains private keys, so store it encrypted or on a machine you trust. To restore on a new host, create the stack once so the volume exists, run docker compose down, extract the archive into the volume's mountpoint, and start it again. As long as the server keys and the public host name stay the same, existing client configs keep working; if the public IP changed, update the DNS record (or the host in Admin Panel, Config, which requires re-issuing client configs).

Updates

The basic installation guide updates with:

Terminal window with the commands from the section Updates
Pull the new image and recreate the container.
cd /etc/docker/containers/wg-easy
sudo docker compose pull
sudo docker compose up -d

With the 15 tag this stays within v15. Read the release notes before pulling, and take a backup first. A future major version will need a deliberate tag change, just like the v14 to v15 move.

Troubleshooting

Clients never connect (no handshake)

  • Port not reachable. Check UDP 51820 in the host firewall (sudo ufw status or sudo nft list ruleset), in your cloud provider's firewall or security group, and on any home router port forward. Cloud firewalls are separate from the OS firewall.
  • Wrong host in client configs. Open a client's .conf and look at the Endpoint line. It must be your public IP or a name that resolves to it; dig +short wg.example.com should return that IP. Fix the host under Admin Panel, Config, then re-issue the client config.
  • Container problems. Run sudo docker compose logs wg-easy. The wg-easy FAQ covers errors such as Cannot find device "wg0" (load the module with sudo modprobe wireguard) and missing iptables nat or filter tables (load iptable_nat, ip6table_nat and friends).

Connected, but no internet or no LAN access

  • Confirm the sysctls and the NET_ADMIN/SYS_MODULE capabilities are still in the compose file; without forwarding, the handshake works but traffic stops at the server.
  • Check the Device under Admin Panel, Interface. It must be the network interface that leads to the internet inside the container.
  • If you attached the container to a second Docker network (for a reverse proxy), the FAQ explains that Docker can swap eth0 and eth1 on restart. Set interface_name and gw_priority in the compose file as shown there.
  • Test DNS separately: if ping 1.1.1.1 works through the tunnel but names don't resolve, the client DNS server is wrong or unreachable through the tunnel.

Can't log in to the web UI

"You can't log in with an insecure connection" means you're on plain HTTP without INSECURE=true. Use the HTTPS proxy, or (on a trusted LAN only) set the variable and recreate the container with docker compose up -d.

Summary

  • Use the ghcr.io/wg-easy/wg-easy:15 tag, not latest, and configure host, DNS and Allowed IPs in the Admin Panel, not with v14 variables.
  • Open UDP 51820 everywhere it can be blocked; keep the 51821 admin panel behind HTTPS.
  • Back up the etc_wireguard volume; it holds every key.