SSD Nodes Learn 8GB RAM — $66/yr
Guides Matt ConnorBy Matt Connor

wg-easy: WireGuard with a web UI in Docker

Run WireGuard behind the wg-easy web UI with Docker Compose: the ports, NET_ADMIN, the sysctls that matter, and QR code onboarding for phones.

What you are building

wg-easy is WireGuard with a web interface, running as one Docker container. It manages the WireGuard interface for you and adds a browser UI for creating clients. Every client you create gets a config file and a QR code, so a phone joins the VPN by pointing its camera at the screen.

The tunnel itself is ordinary WireGuard. The kernel module moves the packets, so throughput is the same as a hand written setup. What you gain is the client lifecycle: adding, disabling and deleting peers without editing a config file over SSH. What you give up is direct control of that config, which is the subject of the manual WireGuard setup on a VPS.

You need a KVM VPS with a public IPv4 address, Docker Engine with the Compose plugin, and root access. Container virtualisation that shares the host kernel, such as OpenVZ or LXC, usually cannot load the WireGuard module, and the container will fail to bring the interface up.

Version 15 moved the settings out of the environment

Most guides you will find were written for wg-easy 14, where you set WG_HOST to your server address and PASSWORD_HASH to a bcrypt hash of the admin password, both as environment variables. Version 15 is a rewrite. The official migration notes say plainly that v15 does not use the same environment variables as v14, and that most of them moved to the admin panel in the web UI.

So WG_HOST and PASSWORD_HASH no longer do anything. If you copy an old compose file, the container starts, ignores those lines, and then asks you to create an admin account in the browser. That is not a bug. It is the new setup flow.

As of July 2026 the major tag to pin is 15. Pin the major version rather than using latest, because a major upgrade changes the on-disk config format and will not roll back cleanly.

The compose file

Create a directory for the stack and write the official compose file into it. This is the upstream file, unchanged.

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

The contents look like this:

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

etc_wireguard is a named volume holding the server key and every client you create. Back that volume up, or a rebuild throws away all your peers. If you would rather see those files on the host filesystem, swap it for a bind mount, and read the difference between bind mounts and named volumes before you do, because the permissions behave differently.

Why it needs NET_ADMIN, SYS_MODULE and the sysctls

A container is not allowed to touch the network stack by default, and each of these lines removes one specific block.

NET_ADMIN lets the container create the wg0 interface, assign it an address and write routes. Without it the container starts and then dies while bringing the interface up, because ip link add wg0 type wireguard returns Operation not permitted.

SYS_MODULE plus the read-only /lib/modules mount lets the container load the WireGuard kernel module if the host has not loaded it already. The module lives on the host kernel, not inside the image, which is why the host directory has to be visible. On a modern kernel the module is usually built in, and you can confirm with sudo modprobe wireguard && echo ok on the host.

net.ipv4.ip_forward=1 makes the kernel forward packets that are not addressed to the box itself. Without it a client connects, the handshake succeeds, and then every packet to the internet is dropped, so ping 1.1.1.1 times out while the VPN looks connected.

net.ipv4.conf.all.src_valid_mark=1 is the one that surprises people. WireGuard marks its own outgoing packets so they are not routed back into the tunnel. Strict reverse path filtering sees a packet whose source address does not match the expected route and drops it. This sysctl tells the kernel to accept marked packets, which is what keeps a full tunnel from breaking itself.

Start it and create the admin account

cd /etc/docker/containers/wg-easy
sudo docker compose up -d
sudo docker compose logs -f

Use docker compose up and docker compose down, not start and stop. Upstream warns that start on a container created under different settings leaves the network in an inconsistent state. If you want the stack back after a reboot, restart: unless-stopped already covers it, and the boot behaviour of compose services explains what that policy does and does not promise.

The web UI listens on TCP 51821. On first visit it shows a setup page where you create the admin account and confirm the host address that clients will use to reach the server. That host address ends up in the Endpoint line of every client config, so it must be the public IP or the DNS name of the VPS. If it is wrong, the QR code you hand a phone points somewhere unreachable and the handshake never completes.

One more thing about that port: wg-easy 15 refuses plain HTTP unless you set INSECURE=true. Reaching it over HTTPS with an untrusted certificate, or terminating TLS at a reverse proxy in front of it, are both fine. Reaching it over http:// with the default settings is not.

Do not publish the UI port to the internet

The compose file publishes 51821 on every interface. That is a login page for a box that can route your traffic, and it should not be open to the world. Publishing a port in Docker writes rules into the DOCKER chain, which is evaluated before ufw, so a ufw deny rule does not close it. This trap is worth understanding on its own, and why Docker published ports ignore ufw covers it in full.

The simple fix is to bind the UI to loopback and reach it through an SSH tunnel:

    ports:
      - "51820:51820/udp"
      - "127.0.0.1:51821:51821/tcp"
    environment:
      - INSECURE=true

Then from your laptop:

ssh -L 51821:127.0.0.1:51821 youruser@your.server.address

Open http://127.0.0.1:51821 in the browser on your laptop. The traffic is encrypted by SSH, the port answers nobody else, and INSECURE=true is safe here because the plain HTTP hop never leaves the loopback interface.

Open UDP 51820, and check both firewalls

WireGuard itself needs UDP 51820 reachable from the internet. Docker publishes it, but many providers put a separate network firewall in front of the VPS that Docker knows nothing about. Open the port in both places. If you manage the host firewall with ufw, the basic ufw rules for a VPS is the shorter path than writing nftables by hand.

Check that the container is actually listening:

sudo ss -ulnp | grep 51820

You should see a listening UDP socket. Nothing on that line means the container never brought the interface up, and sudo docker compose logs wg-easy will name the reason.

Create a client and scan it on a phone

In the UI, create a client and give it a name you will recognise later, such as the device it belongs to. wg-easy allocates the next free tunnel address and generates the key pair for you. Each client row offers a QR code and a downloadable .conf file.

Install the official WireGuard app on the phone, choose to add a tunnel from a QR code, and point the camera at the code on your screen. The tunnel appears with the name you typed. Turn it on, and the client row in the UI starts showing transfer counters and a recent handshake time.

A client that shows no handshake after you enable it is not reaching the server at all. That points at UDP 51820, either at the provider firewall or at the endpoint address baked into the config. A client that shows a handshake but no working internet points at forwarding or at DNS instead.

On a desktop, download the .conf file and import it into the WireGuard client rather than retyping it. The private key in that file is generated once and shown once. Treat the file the way you treat an SSH private key.

When to outgrow the UI

wg-easy is the right tool while your peers are people and phones. The UI is faster than editing config files, and revoking a lost phone takes one click.

You will hit its limits when you want something the UI does not model. Site to site routing, where a peer's AllowedIPs covers a whole remote subnet rather than a single address, is the usual first wall. Split tunnels with per-peer routing rules, or a config generated by your provisioning tool, are the next. At that point the hand written setup is not harder, it is just different, and the plain WireGuard guide shows the same tunnel built from wg0.conf. If you would rather stop running the control plane at all, WireGuard compared with Tailscale covers the managed option.

If the compose syntax above was the unfamiliar part rather than the WireGuard part, Docker Compose basics on a VPS explains the file format and the everyday commands.

FAQ

Why does wg-easy ignore my WG_HOST and PASSWORD_HASH?

Those variables belong to wg-easy 14. Version 15 is a rewrite, and upstream moved almost all configuration into the admin panel in the web UI. The container reads neither variable, so it starts normally and then asks you to create an admin account on first visit. Set the client-facing host address on that setup page instead.

Do I need SYS_MODULE if my kernel already has WireGuard?

No. SYS_MODULE and the /lib/modules mount exist so the container can load the module when the host has not. On a host where sudo modprobe wireguard already succeeds, the capability is unused. Removing it is a reasonable hardening step, and NET_ADMIN is still required either way.

The client connects but there is no internet. What is wrong?

A handshake with no traffic almost always means forwarding. Confirm net.ipv4.ip_forward=1 and net.ipv4.conf.all.src_valid_mark=1 are still in the compose file, since a hand edited copy often loses them. If forwarding is on, check the DNS server the client received. A tunnel that sends all traffic through the VPN but points at a DNS server it can no longer reach looks exactly like a dead connection in a browser.

How do I back up my clients?

Everything lives in the etc_wireguard named volume, in a wg0.json file. The UI also has a backup button that exports the same data. Copy that file somewhere off the server before any upgrade. Restoring is an upload during the setup step on a fresh container.

Can I run wg-easy behind a reverse proxy?

Yes. Put the proxy in front of TCP 51821, terminate TLS there, and set INSECURE=true on the container so it accepts the plain HTTP hop from the proxy. Keep UDP 51820 published directly, because the VPN traffic is UDP and does not pass through an HTTP proxy.