SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor

wg-easy WireGuard Docker setup with web UI

Set up wg-easy with Docker Compose, NET_ADMIN, required sysctls, ports, and phone QR onboarding. Version 15 moves settings out of environment variables.

Wetin you dey build

wg-easy na WireGuard wey get web interface, and e dey run as one Docker container. E dey manage WireGuard interface for you and add browser UI wey you fit use create clients. Every client wey you create go get config file and QR code, so phone fit join the VPN when you point camera am at the screen.

The tunnel itself na ordinary WireGuard. Kernel module dey move the packets, so throughput dey same as manual setup. Wetin you gain na client lifecycle: you fit add, disable, and delete peers without editing config file over SSH. Wetin you lose na direct control of that config. Na this manual WireGuard setup for VPS dey explain.

You need Linux host wey you fit manage, like KVM VPS, with public IP address or domain name wey point to am, x86_64 or arm64 CPU, Docker Engine with Compose plugin, and root access. Container virtualisation wey share host kernel, like OpenVZ or LXC, usually no fit load WireGuard module. Because of this, container go fail to bring interface up.

Version 15 don move the settings comot environment

Most guides wey you go find na for wg-easy 14, where you set WG_HOST to your server address and PASSWORD_HASH to bcrypt hash of the admin password, both as environment variables. Version 15 na complete rewrite. The official migration notes talk am plain: v15 no dey use the same environment variables as v14, and most of dem don move go the admin panel for web UI.

So WG_HOST and PASSWORD_HASH no dey do anything again. If you copy old compose file, the container go start, ignore those lines, then ask you to create admin account for browser. This no be bug. Na the new setup flow.

As of October 2026, the major tag wey you suppose pin na 15. Pin the major version instead of using latest, because major upgrade dey change the on-disk config format and e no go roll back cleanly. The move to 15 replace v14 JSON file with SQLite database.

The compose file

Stack dey create a directory, then write the official compose file inside am. Na upstream file be this, and e no include the optional lines wey dem comment 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 dey 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 na named volume wey dey hold the server key and every client wey you create. Dem store everything for the SQLite database wg-easy.db. Back up this volume, otherwise rebuild go delete all your peers. If you prefer make those files dey for the host filesystem, replace am with a bind mount. Before you do that, read the difference between bind mounts and named volumes, because permissions dey behave differently.

Why e need NET_ADMIN, SYS_MODULE and the sysctls

By default, container no get permission to touch network stack, and each of these lines dey remove one specific block.

NET_ADMIN let the container create the wg0 interface, assign address to am and write routes. Without am, the container go start, then die while e dey bring the interface up, because ip link add wg0 type wireguard dey return Operation not permitted.

SYS_MODULE together with the read-only /lib/modules mount let the container load WireGuard kernel module if host never load am before. The module dey inside host kernel, no be inside the image, na why the host directory need dey visible. For modern kernel, the module usually dey built in, and you fit confirm am with sudo modprobe wireguard && echo ok for the host.

net.ipv4.ip_forward=1 make the kernel forward packets wey no address to the box itself. Without am, client go connect, handshake go succeed, then every packet to the internet go drop, so ping 1.1.1.1 go time out while VPN dey look connected.

net.ipv4.conf.all.src_valid_mark=1 na the one wey dey surprise people. WireGuard mark its own outgoing packets so dem no go route back inside the tunnel. Strict reverse path filtering see packet wey source address no match the expected route and drop am. This sysctl tell the kernel to accept marked packets, and na this dey stop full tunnel from breaking itself.

Start am 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, no be start and stop. start only dey restart containers wey already exist, so any change wey you make for compose file, including network and sysctl settings, no apply, while up dey recreate anything wey change. If you want the stack to come back after reboot, restart: unless-stopped don already cover am, and compose services boot behaviour explain wetin that policy promise and wetin e no promise.

The web UI dey listen for TCP 51821. The first time you visit am, e go show setup page where you go create admin account and confirm the host address wey clients go use to reach the server. That host address go enter the Endpoint line for every client config, so e must be the public IP or DNS name of the VPS. If e no correct, the QR code wey you give phone go point to somewhere wey e no fit reach, and the handshake no go complete.

One more thing about that port: wg-easy no dey serve TLS by itself, and e no dey reject plain HTTP. Through http://, the UI still loads and shows warning banner. The thing wey default changes na the session cookie, wey carries the Secure flag, so browser wey reaches the UI through plain HTTP on public address go discard am and login no go remain active. INSECURE=true removes that flag and does nothing else. If you put reverse proxy for front and make e terminate TLS, even with self-signed certificate, you no need change anything for wg-easy.

No publish the UI port to the internet

The compose file publishes 51821 on every interface. Na login page for machine wey fit route your traffic, and e no suppose open to everybody. When you publish port for Docker, e writes rules into the DOCKER chain, wey system evaluates before ufw, so ufw deny rule no go close am. This trap worth understanding by itself, and why Docker published ports dey ignore ufw explain am fully.

The simple fix na to bind the UI to loopback and reach am through 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 for browser on your laptop. SSH encrypts the traffic, and nobody else fit get answer from the port. INSECURE=true only removes the Secure flag from the session cookie so browser keeps your login for this plain HTTP address, and this one safe here because the plain HTTP hop never leaves the loopback interface.

UDP 51820 open, then check both firewalls

WireGuard need UDP 51820 make e reachable from internet. Docker publish am, but many providers dey put another network firewall in front of the VPS wey Docker no know anything about. Open the port for both places. If na ufw you dey use manage host firewall, the basic ufw rules for a VPS na shorter way pass writing nftables by hand.

Check say the container dey listen:

sudo ss -ulnp | grep 51820

You suppose see UDP socket wey dey listen. If nothing dey for that line, e mean say the container never bring the interface up, and sudo docker compose logs wg-easy go show the reason.

Create and scan client for phone

For UI, create one client and give am name wey you go recognise later, like the device wey e belong to. wg-easy go allocate the next free tunnel address and generate the key pair for you. Every client row get QR code and downloadable .conf file.

Install official WireGuard app for the phone. Choose make you add tunnel from QR code, then point camera to the code for your screen. The tunnel go show with the name wey you type. Turn am on. The client row for UI go start show transfer counters and recent handshake time. Once phone dey on the tunnel, e fit reach services wey you never publish to internet. Na so phone fit continue upload to self-hosted photo server from anywhere, without that server opening even one port to everybody for internet. The same method work for media too. Jellyfin library wey you rebuild as 90s video store fit remain private like e dey for your LAN, while you browse am from hotel room. Alerts fit work the other way for the same tunnel. self-hosted ntfy server fit push message go that phone immediately backup job fail, without ever answering request from public internet.

If client no show handshake after you enable am, e no dey reach server at all. Check UDP 51820, either for provider firewall or for endpoint address wey config contain. If client show handshake but internet no work, check forwarding or DNS instead.

For desktop, download .conf file and import am into WireGuard client instead of typing am again. The private key for that file generate once and show once. Treat the file the same way you go treat SSH private key.

When UI no longer enough

wg-easy na the correct tool while your peers na people and phones. UI dey faster than editing config files, and you fit revoke lost phone with one click.

You go reach the limit when you want something wey UI no model. Site-to-site routing, where peer's AllowedIPs covers whole remote subnet instead of one address, na usually the first problem. Split tunnels with per-peer routing rules, or config wey your provisioning tool generate, na the next one. For that point, manual setup no harder; e just different. The plain WireGuard guide show the same tunnel built from wg0.conf. If you prefer stop running control plane completely, WireGuard compared with Tailscale explain the managed option. Whether that trade-off make sense depend on wetin coordination server fit actually reach. Read Tailscale trust model before you give am access to your network. Cost usually na the next question. Wetin Tailscale free plan really cover na up to six users at no cost, for non-commercial use only. This mean household no pay anything, but small business team no qualify, no matter how few people e get. After that, billing count users instead of devices. This billing shape different from VPS wey you already dey pay for. So check Wetin Tailscale cost after you pass the free plan before you migrate team. The full tunnel wey you just build get direct equivalent there. Advertising VPS as Tailscale exit node give you the same route out through the server. You approve am for admin console instead of writing am inside every client config. The subnet limit get equivalent too. Advertising whole private network from VPS hand that network to every device inside the tailnet without the per-peer AllowedIPs editing wey make you leave the UI. If you want that dashboard and automatic mesh routing, but you no want another person coordination server, Running your own NetBird server on a VPS keep control plane for hardware wey you own. But you go need set up DNS and TLS, which wg-easy never ask you for.

If na Compose syntax above wey you no familiar with, instead of the WireGuard part, Docker Compose basics for VPS explain the file format and everyday commands.

FAQ

Why wg-easy dey ignore my WG_HOST and PASSWORD_HASH?

Those variables belong to wg-easy 14. Version 15 na rewrite, and upstream move almost all configuration go the admin panel for the web UI. The container no dey read either variable, so e starts normally and then asks you to create admin account when you visit am first time. Set the client-facing host address for that setup page instead.

I need SYS_MODULE if my kernel already get WireGuard?

No. SYS_MODULE and the /lib/modules mount dey there so container fit load the module when host no get am. For host where sudo modprobe wireguard already succeeds, the capability no dey used. Removing am na reasonable hardening step, and NET_ADMIN still dey required either way.

Client connects but internet no dey work. Wetin dey wrong?

Handshake wey no get traffic almost always mean forwarding problem. Confirm say net.ipv4.ip_forward=1 and net.ipv4.conf.all.src_valid_mark=1 still dey inside compose file, because copy wey person edit by hand often lose dem. If forwarding dey on, check the DNS server wey client receive. Tunnel wey sends all traffic through VPN but points to DNS server wey e no fit reach again go look exactly like dead connection for browser.

How I fit back up my clients?

Everything dey inside the etc_wireguard named volume. For version 15, that means SQLite database for /etc/wireguard/wg-easy.db inside the container. The v15 docs no describe backup or export feature, so back up the volume itself: run docker compose down, copy the volume contents go somewhere outside the server, then bring the stack up again with docker compose up -d. Do this before any upgrade. The wg0.json file wey older guides mention belong to wg-easy 14. Na the file wey v14 backup button exports, and for v15, the only use na as the file wey you upload for setup wizard when you migrate old install.

I fit run wg-easy behind 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 e be, and the official Caddy and Traefik examples no set any INSECURE variable at all. Since browser dey talk HTTPS to proxy, you want session cookie to keep its Secure flag, and na only INSECURE=true dey remove that flag. Keep UDP 51820 published directly, because VPN traffic na UDP and e no pass through HTTP proxy.

Dis article help you?