SSD Nodes Learn Hosting plans →
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 Linux host you can manage, such as a KVM VPS, with a public IP address or a domain name that points at it, an x86_64 or arm64 CPU, 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 October 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 move to 15 replaced v14's JSON file with a SQLite database.

The compose file

Create a directory for the stack and write the official compose file into it. This is the upstream file, with its commented-out optional lines left out.

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, stored in the SQLite database wg-easy.db. 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. start only restarts the containers that already exist, so any change you made to the compose file, including the network and sysctl settings, is not applied, while up recreates whatever changed. 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 never serves TLS itself, and it does not refuse plain HTTP. Over http:// the UI still loads and shows a warning banner. What the default changes is the session cookie, which carries the Secure flag, so a browser reaching the UI over plain HTTP on a public address discards it and the login does not stick. INSECURE=true removes that flag and does nothing else. Putting a reverse proxy in front that terminates TLS, even with a self-signed certificate, needs no change to wg-easy at all.

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 and the port answers nobody else. INSECURE=true only drops the Secure flag from the session cookie so the browser keeps your login on this plain HTTP address, and that 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. Once a phone is on the tunnel it can reach services you never published to the internet, which is how a phone keeps uploading to a self-hosted photo server from anywhere without that server having a single port open to the world. The same trick covers media, and a Jellyfin library rebuilt as a 90s video store is a pleasant thing to browse from a hotel room while it stays as private as it was on your LAN. Alerts work the other way round on the same tunnel, since a self-hosted ntfy server can push a message to that phone the moment a backup job fails without ever answering a request from the public internet.

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. Whether that is a fair trade depends on what the coordination server can actually reach, and Tailscale's trust model is worth reading before you hand it your network. Cost is usually the next question, and what the Tailscale free plan actually covers is up to six users at no cost, for non-commercial use only, so a household pays nothing for it while a small business team does not qualify however few people it has. Past that point the billing counts users rather than devices, which is a different shape of bill from a VPS you already pay for, so what Tailscale costs once you outgrow the free plan is the number to check before you migrate a team. The full tunnel you just built has a direct equivalent there, since advertising the VPS as a Tailscale exit node gets you the same route out through the server, approved in the admin console instead of written into each client config. The subnet wall has an equivalent too, because advertising a whole private network from the VPS hands that network to every device in the tailnet without the per-peer AllowedIPs editing that pushed you off the UI. If you want that dashboard and the automatic mesh routing but not somebody else's coordination server, running your own NetBird server on a VPS keeps the control plane on hardware you own, at the cost of the DNS and TLS setup that wg-easy never asked you for.

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 version 15 that means a SQLite database at /etc/wireguard/wg-easy.db inside the container. The v15 docs describe no backup or export feature, so back up the volume itself: run docker compose down, copy the volume's contents somewhere off the server, then bring the stack back up with docker compose up -d. Do this before any upgrade. The wg0.json file older guides mention belongs to wg-easy 14. It is what the v14 backup button exports, and in v15 its only use is as the file you upload in the setup wizard when you migrate an old install.

Can I run wg-easy behind a reverse proxy?

Yes. Put the proxy in front of the web UI port, terminate TLS there, and leave INSECURE unset. The container accepts the plain HTTP hop from the proxy as it is, and the official Caddy and Traefik examples set no INSECURE variable at all. Since the browser talks HTTPS to the proxy, you want the session cookie to keep its Secure flag, which is the only thing INSECURE=true removes. Keep UDP 51820 published directly, because the VPN traffic is UDP and does not pass through an HTTP proxy.

Was this article useful?