Start wg-easy from its stock Docker Compose file and the wizard runs fine, then the login page says “You can’t log in with an insecure connection. Use HTTPS.” Nothing is broken in your wg-easy Docker install. Since v15 the session cookie carries the Secure flag, so a browser on plain HTTP throws it away and you land back on the login form, even with the right password.
This guide puts Caddy in front of wg-easy with Docker Compose before the first start, so the wizard and the web UI are only ever served over HTTPS, then connects a real client. After that it covers the experimental AmneziaWG mode and the v14 to v15 migration through a wg0.json import. Every step below, except where we flag it as untested, was run in October 2026 on Ubuntu 26.04 and 24.04 lab VMs against wg-easy 15.5.0, the release current that month, and the AmneziaWG section keeps the kernel build failure we hit on a fully updated 26.04 box.
How wg-easy fits together
wg-easy is one container that runs both the WireGuard interface and a Node.js web UI for managing peers. You get QR codes, per-client toggles and traffic counters instead of hand-editing wg0.conf as you would on a plain WireGuard server. The container has no TLS of its own, so the layout we kept puts Caddy in front of the UI on the same Docker host and publishes only two services to the network.

The admin browser talks to Caddy on 443, Caddy forwards to the UI on the internal caddy Docker network, and VPN clients never touch either of them because their traffic goes straight to UDP 51820. So the only ports you open are UDP 51820 for WireGuard and TCP 80 and 443 for Caddy. The stock compose file also publishes the UI on TCP 51821, which this guide never does.
The other big change from v14 is where settings live. WG_HOST, PASSWORD and PASSWORD_HASH are gone, so tutorials that tell you to generate a bcrypt hash with wgpw describe the old release. The admin account and the public host are set in a first-run wizard and stored in a database inside the etc_wireguard volume.
Preparing the server
Any VM or bare-metal box with a public address (or a port forward from your router) will do, because wg-easy is light. The image is 321 MB, and with one client connected docker stats showed wg-easy at 202 MiB of RAM and Caddy at 61 MiB, both near 0% CPU. We ran the lab on two Proxmox VMs, and a full VM needs nothing special since it loads its own kernel modules. What the server needs:
- Ubuntu 26.04 or 24.04 with sudo access
- 1 GB RAM or more (our lab VMs had 2 vCPUs and 4 GB, used 571 MiB in total)
- A DNS name such as
vpn.example.compointing at the server - UDP 51820 and TCP 80/443 open on the cloud firewall or router
The hostname shows up in the Caddyfile, the wizard and the checks later on, and the email is what Caddy registers with the certificate authority, so set both once for this shell. Swap in your own values, and run the exports again if you reconnect later, because a new shell starts without them.
export VPN_DOMAIN="vpn.example.com"
export ADMIN_EMAIL="[email protected]"
wg-easy needs Docker Engine with the Compose plugin, not the docker.io package. Docker’s apt repository already publishes a resolute suite for 26.04, so the same commands work on both releases (the Docker CE install guide explains each one):
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc" | sudo tee /etc/apt/sources.list.d/docker.sources
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Confirm both the engine and the Compose plugin answer before you go further. If docker compose version errors out, the plugin package is missing, and every later step depends on it.
docker --version; docker compose version
Our 26.04 server reported these versions, and the 24.04 VM pulled the same ones from the noble suite.
Docker version 29.9.2, build a82e528
Docker Compose version v5.6.1
One firewall trap catches people who rely on UFW, because ports that Docker publishes skip UFW’s incoming rules. On our server, with UFW active, deny (incoming) as the default and only 22/tcp allowed, Caddy on 443 still answered with HTTP 200 from another machine. Filter those ports at the cloud firewall or router, or follow Docker’s packet-filtering notes; the UFW setup on its own will not hide them.
Setting up Caddy for HTTPS
Caddy goes in first, before wg-easy ever starts, because the v15 setup wizard has no login of its own. Whoever reaches it first creates the admin account, so the wizard should only ever be served over HTTPS, and you should finish it right after wg-easy starts. HTTPS keeps the password off the wire but doesn’t stop someone else reaching the page first. A public certificate also puts the name in Certificate Transparency logs that scanners read. Caddy runs as its own Compose project and creates a Docker network named caddy that wg-easy joins in the next section, which lets Caddy reach the UI by container name without the UI port ever being published. Create its directory and compose file (the Caddy on Ubuntu guide covers a host install if you’d rather not containerise it):
sudo mkdir -p /etc/docker/containers/caddy
sudo vim /etc/docker/containers/caddy/compose.yml
Paste this file. The data volume keeps certificates across restarts, and 443/udp lets Caddy serve HTTP/3.
services:
caddy:
container_name: caddy
image: caddy:2-alpine
ports:
- "80:80/tcp"
- "443:443/tcp"
- "443:443/udp"
networks:
- caddy
restart: unless-stopped
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- config:/config
- data:/data
networks:
caddy:
name: caddy
volumes:
config:
data:
Next comes the Caddyfile, which tells Caddy which name to serve, where to send the traffic and which address to register with the certificate authority.
sudo vim /etc/docker/containers/caddy/Caddyfile
Add the following. VPN_DOMAIN_HERE and ADMIN_EMAIL_HERE are placeholders that the next command fills from your exported variables, so leave them as written.
{
email ADMIN_EMAIL_HERE
}
VPN_DOMAIN_HERE {
reverse_proxy wg-easy:80
tls internal
}
That is the file our lab ran, and the tls internal line is the one decision to make before you start Caddy:
- Name only resolves on your LAN: keep
tls internal - Public DNS record, ports 80 and 443 reachable: delete that line
- Either way: a real
ADMIN_EMAIL, never an example.com address
With tls internal, Caddy signs the certificate from its own local CA, which is what we used because the lab name only exists on our LAN. Without it, Caddy requests a certificate from Let’s Encrypt on first start. We did not run that public variant in this lab, so check Caddy’s log the first time with sudo docker compose logs caddy | grep -i "certificate obtained" (ours printed certificate obtained successfully with "issuer":"local"; the public path should name Let’s Encrypt), and keep the email real because Let’s Encrypt refuses to register accounts with example.com contacts. Substitute both values, check the syntax with Caddy’s own validator, then start the proxy:
sudo sed -i "s/VPN_DOMAIN_HERE/${VPN_DOMAIN}/; s/ADMIN_EMAIL_HERE/${ADMIN_EMAIL}/" /etc/docker/containers/caddy/Caddyfile
sudo docker run --rm -v /etc/docker/containers/caddy/Caddyfile:/etc/caddy/Caddyfile:ro caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
cd /etc/docker/containers/caddy && sudo docker compose up -d
The validator prints Valid configuration among its log lines. Validating first matters because a broken Caddyfile makes the container restart in a loop, and the UI goes with it. The caddy network now exists, which wg-easy needs before it can start.
Deploying the wg-easy Docker Compose stack
We started from the project’s own compose file, because it already carries the capabilities, sysctls and IPv6 network the container expects. Run unchanged, though, it publishes the web UI on TCP 51821 over plain HTTP, and on a public server that is an unclaimed setup wizard anyone can finish. The version below is the upstream file with four changes, and it is exactly what our server ran:
PORT=80, the port Caddy forwards to- no
51821mapping, so the UI is never published - the external
caddynetwork - fixed interface order with
interface_nameandgw_priority
The interface pinning comes from the project FAQ. With two networks attached, Docker can swap eth0 and eth1 on a restart, and the NAT rule wg-easy writes for eth0 then points at the wrong network. Keep the file under /etc/docker/containers/wg-easy, the path the upstream docs and CLI examples use:
sudo mkdir -p /etc/docker/containers/wg-easy
sudo vim /etc/docker/containers/wg-easy/docker-compose.yml
Paste the compose file. The image tag ghcr.io/wg-easy/wg-easy:15 follows the newest minor release without crossing into a new major; avoid :latest, since the project’s tag table says it still points at the old v14 release.
volumes:
etc_wireguard:
services:
wg-easy:
environment:
- PORT=80
image: ghcr.io/wg-easy/wg-easy:15
container_name: wg-easy
networks:
wg:
interface_name: eth0
gw_priority: 1
ipv4_address: 10.42.42.42
ipv6_address: fdcc:ad94:bacf:61a3::2a
caddy:
interface_name: eth1
gw_priority: 0
volumes:
- etc_wireguard:/etc/wireguard
- /lib/modules:/lib/modules:ro
ports:
- "51820:51820/udp"
restart: unless-stopped
cap_add:
- NET_ADMIN
- SYS_MODULE
# - NET_RAW # Uncomment if using Podman
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
caddy:
external: true
Validate the file and start the container. The first start generates the server keys and brings up wg0, and the log banner tells you which build you got.
cd /etc/docker/containers/wg-easy
sudo docker compose config -q && sudo docker compose up -d
sudo docker compose logs wg-easy | head -22
A healthy first boot prints the version banner, generates keys and starts the interface, with the UI listening on port 80 inside the container only.
wg-easy | 2026-10-25T09:12:32.684Z Database Migrating database...
wg-easy |
wg-easy | ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
wg-easy | ┃ wg-easy - https://github.com/wg-easy/wg-easy ┃
wg-easy | ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
wg-easy | ┃ wg-easy: v15.5.0 ┃
wg-easy | ┃ Node: v24.22.1 ┃
wg-easy | ┃ Platform: linux ┃
wg-easy | ┃ Arch: x64 ┃
wg-easy | ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
wg-easy |
wg-easy | 2026-10-25T09:12:32.788Z Database Migration complete
wg-easy | 2026-10-25T09:12:32.813Z WireGuard Starting WireGuard...
wg-easy | 2026-10-25T09:12:32.857Z WireGuard Generating new Wireguard Keys...
wg-easy | 2026-10-25T09:12:32.857Z CMD $ wg genkey
wg-easy | Listening on http://0.0.0.0:80
wg-easy | 2026-10-25T09:12:32.887Z CMD $ echo ***hidden*** | wg pubkey
wg-easy | 2026-10-25T09:12:32.920Z WireGuard New Wireguard Keys generated successfully.
wg-easy | 2026-10-25T09:12:32.921Z WireGuard Generating random AmneziaWG obfuscation parameters...
wg-easy | 2026-10-25T09:12:32.922Z WireGuard Starting Wireguard Interface wg0...
wg-easy | 2026-10-25T09:12:32.947Z WireGuard Saving Config...
wg-easy | 2026-10-25T09:12:32.949Z WireGuard Config saved successfully.
Notice the “Generating random AmneziaWG obfuscation parameters” line even though AmneziaWG is off. The values are stored now and only used if you enable that mode later. Then confirm the interfaces landed where the NAT rule expects them:
sudo docker exec wg-easy ip -br addr
eth0 must hold the 10.42.42.42 address from the wg network, and wg0 the VPN gateway address. If eth1 carries the 10.42.42.42 address instead, the NAT rule targets the wrong network and clients connect without reaching anything.
lo UNKNOWN 127.0.0.1/8 ::1/128
eth1@if17 UP 172.18.0.3/16
eth0@if18 UP 10.42.42.42/24 fdcc:ad94:bacf:61a3::2a/64 fe80::e0a9:90ff:fe42:1838/64
wg0 UNKNOWN 10.8.0.1/24 fdcc:ad94:bacf:61a4::cafe:1/112
If you kept tls internal, your admin browser won’t trust the certificate until you import Caddy’s root CA on the machine you manage the VPN from. Copy it out of the container, make it readable (as copied it was mode 600 and owned by root, and curl failed with error 77 until the chmod), and test the chain from the server itself:
cd /etc/docker/containers/caddy
sudo docker compose cp caddy:/data/caddy/pki/authorities/local/root.crt ./caddy-local-root.crt
sudo chmod 644 caddy-local-root.crt
curl -sSI --resolve "${VPN_DOMAIN}:443:127.0.0.1" --cacert ./caddy-local-root.crt "https://${VPN_DOMAIN}/login" | head -6
An HTTP/2 200 with no certificate complaint from curl means the chain is good:
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
content-type: text/html;charset=utf-8
date: Sun, 25 Oct 2026 09:14:10 GMT
set-cookie: i18n_redirected=en; Path=/; Expires=Mon, 25 Oct 2027 09:14:10 GMT; SameSite=Lax
set-cookie: uiShowCharts=false; Max-Age=31536000; Path=/
Copy caddy-local-root.crt to your workstation (with scp, for example) and import it into the operating system or browser certificate store; how that’s done differs per OS, and we didn’t test a specific desktop import here. Two more checks from our lab are worth repeating on yours. Plain HTTP on port 80 answers with 308 Permanent Redirect to the HTTPS URL, and a request to port 51821 from another machine got no answer because nothing is published there.
Walking through the first-run wizard
Browse to https://vpn.example.com as soon as the container is up, and the wizard opens before anything else, because v15 has no default account. If you get a login page instead of the wizard, someone else finished it first: run sudo docker compose down -v in the wg-easy directory to delete the volume, then start again. The second screen asks for the admin username and password; pick a long one, since this login is the only thing between the internet and your VPN keys and wg-easy has no built-in rate limiting:

Next it asks “Do you have an existing setup?”. Choose No on a fresh server; Yes is the v14 import covered further down. The host and port screen follows, and it’s the one people rush through.

Whatever you type as Host becomes the Endpoint line in every client config, so use the public DNS name your clients can resolve, not a LAN address. Port is both the port clients dial and the one WireGuard listens on inside the container, so it has to match both sides of the 51820:51820/udp mapping. Later, Host and the client-facing port live under Admin Panel, Config, while the listen port moves to Admin Panel, Interface. The wizard ends on “Setup successful” with a Sign In button.
Fixing “You can’t log in with an insecure connection. Use HTTPS”
You only meet this error if wg-easy was started from the unchanged upstream compose file and you browse to http://<server>:51821. The wizard creates your admin account over plain HTTP, and then the login page refuses you. A curl to the login API showed the server answering the correct password with HTTP 200 and set-cookie: wg-easy=...; HttpOnly; Secure. In the browser on our 24.04 VM, that POST /api/auth/password also returned 200, but no wg-easy cookie was stored on the http:// page, the next GET /api/session came back 401 and the form reloaded empty:

The red banner comes from the page checking window.location.protocol, and the cookie flag follows the INSECURE variable on the server. If that stock container sits on a public address, treat it as exposed: the admin password you typed crossed the network in clear text, and until someone finishes the wizard, anyone who finds port 51821 can claim it. An internet-facing server belongs behind Caddy, a UI that never leaves a trusted LAN can use INSECURE=true, and a password already typed over HTTP on a public IP should be changed afterwards.
Moving behind Caddy is the two previous sections: start Caddy, replace the compose file with the Caddy-ready one and run sudo docker compose up -d. As long as the new compose file lives in the same directory as the stock one, the etc_wireguard volume keeps its name, so the account you already created survives. On our 26.04 server we created the admin over HTTP, switched to the Caddy compose, and logged in at the HTTPS URL with the same credentials.
Setting INSECURE=true on a trusted LAN
If the UI only ever lives on a home LAN or behind another VPN, you can skip the proxy. INSECURE=true removes the Secure flag from the session cookie, and on our 24.04 VM the HTTP login then went straight to the client list. The cost is that your admin password and session cookie cross the network in clear text, so never use it on a UI reachable from the internet. On the stock compose file, this uncomments the right lines:
cd /etc/docker/containers/wg-easy
sudo sed -i "s| #environment:| environment:|; s| # - INSECURE=false| - INSECURE=true|" docker-compose.yml
sudo docker compose up -d
Don’t combine it with the Caddy setup. INSECURE only changes how the cookie is issued, Caddy already serves HTTPS, and turning it on behind Caddy only weakens the cookie for nothing.
Adding a client and connecting it
Clients are created from the New button on the Clients page. Only the name is required; Expire Date disables the peer automatically on that day, which is handy for a contractor’s laptop. Names show up in the UI and in cli clients:list, so use the device name rather than the person’s.

Each client row then gets four buttons: edit, QR code, download config and a one-time link. Phones scan the QR code from the WireGuard app. We pixelated ours because the code embeds the client’s private key, and you should treat a screenshot of it the same way you’d treat the key itself:

For a Linux laptop, download the file instead. The one we got for laptop01 routes everything through the tunnel by default (keys redacted here). Edit the client’s Allowed IPs in the UI before downloading if you only want the VPN subnet routed.
[Interface]
PrivateKey = <redacted>
Address = 10.8.0.2/32, fdcc:ad94:bacf:61a4::cafe:2/128
MTU = 1420
DNS = 1.1.1.1, 2606:4700:4700::1111
[Peer]
PublicKey = +MZaHn6Z9PUrRxJWce4yHpBrbyBRGpeMlrlHp1yHbBE=
PresharedKey = <redacted>
AllowedIPs = 0.0.0.0/0, ::/0
PersistentKeepalive = 0
Endpoint = vpn.example.com:51820
On the Ubuntu client, install the tools, put the file where wg-quick looks for it and bring the tunnel up. Run this from a local console rather than over SSH, because AllowedIPs = 0.0.0.0/0 moves the default route into the tunnel and can cut an SSH session that came in over another path:
sudo apt-get install -y wireguard-tools
sudo install -m 600 laptop01.conf /etc/wireguard/wg0.conf
sudo wg-quick up wg0
ping -c 3 10.8.0.1
sudo wg show
The proof is in wg show, which needs a latest handshake a few seconds old and a non-zero received counter. If you see only bytes sent and no handshake line, the UDP packets aren’t reaching the server or the answers aren’t getting back:
interface: wg0
public key: UAcPxLSo5ZXd91Rz3P17Sv+eb+zudAQkW+26J2XfNwM=
private key: (hidden)
listening port: 37454
fwmark: 0xca6c
peer: +MZaHn6Z9PUrRxJWce4yHpBrbyBRGpeMlrlHp1yHbBE=
preshared key: (hidden)
endpoint: 10.0.1.50:51820
allowed ips: 0.0.0.0/0, ::/0
latest handshake: 3 seconds ago
transfer: 7.45 KiB received, 2.97 KiB sent
The server sees the same peer from its side, which you can read without the UI. That is the quicker check when a user says the VPN is down, because it tells you at once whether their handshakes are arriving.
cd /etc/docker/containers/wg-easy && sudo docker compose exec wg-easy wg show
On ours, the peer’s allowed IPs were pinned to the single 10.8.0.2 address wg-easy assigned, with the client’s real source port as the endpoint. That pinning is what stops one client from sending traffic as another client’s address, because WireGuard only accepts packets whose source matches the peer’s allowed IPs.
interface: wg0
public key: +MZaHn6Z9PUrRxJWce4yHpBrbyBRGpeMlrlHp1yHbBE=
private key: (hidden)
listening port: 51820
peer: UAcPxLSo5ZXd91Rz3P17Sv+eb+zudAQkW+26J2XfNwM=
preshared key: (hidden)
endpoint: 10.0.1.60:37454
allowed ips: 10.8.0.2/32, fdcc:ad94:bacf:61a4::cafe:2/128
latest handshake: 12 seconds ago
transfer: 8.26 KiB received, 18.47 KiB sent
Back in the browser, the connected client gets a red dot, a last-seen time and live transfer counters. Bring the tunnel down with sudo wg-quick down wg0 when you’re done testing. The same config file works on a travel router with a WireGuard client, which then puts every device behind it on your VPN.

That’s the core setup done: HTTPS admin, one working peer and only two exposed services. The next two sections are optional and independent of each other.
Enabling AmneziaWG mode (experimental)
AmneziaWG is a modified WireGuard that adds an obfuscation layer. It sends junk packets before the handshake, pads the handshake messages and uses custom header values, so the traffic no longer matches WireGuard’s fixed packet signatures. wg-easy supports it behind EXPERIMENTAL_AWG=true, the docs say the switch becomes the default in v16, and the feature may still change. It needs the AmneziaWG kernel module on the host and an AmneziaWG-capable app on every client, and once it’s on, stock WireGuard clients stop connecting.
Check your kernel before you start. As of October 2026 the PPA’s module does not build on 7.0.0-38, the kernel Ubuntu 26.04 currently ships in resolute-updates, so on a fully patched 26.04 server the install below fails and modprobe amneziawg returns “Module amneziawg not found”. Our 24.04 VM (kernel 6.8) built it cleanly and ran wg-easy in AmneziaWG mode, so if you need AmneziaWG today, put wg-easy on a 24.04 host.
The module comes from the Amnezia PPA as a DKMS package, which builds from source and therefore needs the source repositories enabled and the headers for your running kernel. Run this on the wg-easy server, and later on any Linux client that will use AmneziaWG:
sudo sed -i "s/^Types: deb$/Types: deb deb-src/" /etc/apt/sources.list.d/ubuntu.sources
sudo apt-get install -y software-properties-common python3-launchpadlib gnupg2 linux-headers-$(uname -r)
sudo add-apt-repository -y ppa:amnezia/ppa
sudo apt-get install -y amneziawg
Load the module and confirm it was built for the kernel you are running.
sudo modprobe amneziawg
modinfo amneziawg | grep -E "^(version|vermagic)"
On the 24.04 client (kernel 6.8) the package built cleanly and printed this.
version: 3.1.20260812
vermagic: 6.8.0-101-generic SMP preempt mod_unload modversions
The vermagic line has to name the kernel you’re running; if it names another one, the module will not load after the next reboot.
DKMS build fails: “passing argument 2 of ‘setup_udp_tunnel_sock’ from incompatible pointer type”
The 26.04 server was a different story. It still ran the template’s 7.0.0-15 kernel, with 7.0.0-38 installed by our earlier apt upgrade and waiting for a reboot. DKMS built the module for 7.0.0-15 and then failed on 7.0.0-38, which leaves amneziawg-dkms half-configured. After rebooting into 7.0.0-38 and installing again, the build failed the same way and modprobe printed Module amneziawg not found in directory /lib/modules/7.0.0-38-generic.
Building initial module amneziawg/1.0.0 for 7.0.0-38-generic
Deprecated feature: REMAKE_INITRD (/var/lib/dkms/amneziawg/1.0.0/source/dkms.conf)
Sign command: /usr/bin/kmodsign
Signing key: /var/lib/shim-signed/mok/MOK.priv
Public certificate (MOK): /var/lib/shim-signed/mok/MOK.der
Building module(s).....(bad exit status: 2)
Failed command:
make -j2 KERNELRELEASE=7.0.0-38-generic -C /lib/modules/7.0.0-38-generic/build M=/var/lib/dkms/amneziawg/1.0.0/build
Error! Bad return status for module build on kernel: 7.0.0-38-generic (x86_64)
Consult /var/lib/dkms/amneziawg/1.0.0/build/make.log for more information.
dpkg: error processing package amneziawg-dkms (--configure):
old amneziawg-dkms package postinst maintainer script subprocess failed with exit status 10
The compiler error in make.log is the one in the heading, raised from compat/compat.h because the 7.0.0-38 Ubuntu kernel changed the signature of setup_udp_tunnel_sock. Upstream tracks it in issue #259 (with #266 for 26.04.1), and the fix pull requests were still open when we tested. The failed package also blocks apt: every later apt-get install on our server ended with E: Sub-process /usr/bin/dpkg returned an error code (1), unattended upgrades included. Purging the two packages fixed that:
sudo apt-get remove --purge -y amneziawg amneziawg-dkms
If your running kernel did get a working module, check that it exists for every installed kernel before you reboot:
dkms status amneziawg
Ours listed only the old kernel. That is the warning sign, because the next reboot boots a kernel with no module at all.
amneziawg/1.0.0, 7.0.0-15-generic, x86_64: installed
That listing is too late to fix with a hold, because the new kernel is already installed and is what the next reboot picks. Don’t reboot an AmneziaWG server in that state unless you’re ready for every AmneziaWG client to drop. The hold below only helps on a box where the module works and the next kernel hasn’t arrived yet. It also has to name the kernel metapackage your image actually uses. Ours was a cloud image with linux-image-virtual, not linux-image-generic, so check first:
dpkg -l 'linux-image-*' 'linux-headers-*' | grep ^ii
Hold every unversioned linux-image-* and linux-headers-* meta package it lists. Ours showed the -virtual pair plus linux-headers-generic, which was also installed, and leaving that one free lets apt fetch new headers that DKMS then fails to build against. Release them with sudo apt-mark unhold once the PPA ships a fixed amneziawg-dkms and issue #259 is closed:
sudo apt-mark hold linux-image-virtual linux-headers-virtual linux-headers-generic
apt confirms each one with a set on hold. line, such as linux-image-virtual set on hold., and apt-mark showhold lists them afterwards. Remember the hold also stops kernel security updates, so it’s a stopgap, not a setting to forget.
Switching wg-easy to AmneziaWG
With the module loaded on the running kernel, add the flag under environment in the wg-easy compose file and recreate the container. The sed anchors on the environment: line, so it fits both the Caddy compose and the stock file with INSECURE=true. We ran it with the module loaded on the 24.04 host’s stock file and checked the Caddy file’s result with docker compose config -q:
cd /etc/docker/containers/wg-easy
sudo sed -i "s|^ environment:| environment:\n - EXPERIMENTAL_AWG=true|" docker-compose.yml
sudo docker compose up -d
sudo docker compose logs wg-easy --since 1m | grep -E "modinfo|quick up"
This is the log from our 24.04 host right after that sed. It shows the detection step, where wg-easy runs modinfo amneziawg, finds the module and switches to the awg tools.
wg-easy | 2026-10-25T09:14:14.831Z CMD $ modinfo amneziawg
wg-easy | 2026-10-25T09:14:15.100Z CMD $ awg-quick up wg0
Confirm the mode from inside the container. This one is from the 26.04 server while it still ran the kernel the module built for. The obfuscation parameters generated on first boot are now live, and ip -d link show wg0 inside the container reported the link as amneziawg.
sudo docker compose exec wg-easy awg show
Jc, Jmin and Jmax control the junk packets, S1 and S2 the handshake padding, and H1 to H4 the header values. S and H must match on both ends, which is why wg-easy writes them into every client config:
interface: wg0
public key: +MZaHn6Z9PUrRxJWce4yHpBrbyBRGpeMlrlHp1yHbBE=
private key: (hidden)
listening port: 51820
jc: 7
jmin: 10
jmax: 1000
s1: 128
s2: 56
h1: 426970346
h2: 2002402069
h3: 1041007522
h4: 92731038
random trailers: off
disable cookies: off
peer: UAcPxLSo5ZXd91Rz3P17Sv+eb+zudAQkW+26J2XfNwM=
preshared key: (hidden)
allowed ips: 10.8.0.2/32, fdcc:ad94:bacf:61a4::cafe:2/128
The old laptop01.conf stopped working immediately: the WireGuard client sent 740 bytes of handshake attempts, received nothing, and every ping was lost. Download the config again from the UI, and the new file gains Jc, Jmin, Jmax, S1, S2 and H1 to H4 lines in [Interface]. On a Linux client, install the same amneziawg package from the PPA and bring it up with the awg-quick tool:
sudo mkdir -p /etc/amnezia/amneziawg
sudo install -m 600 laptop01.conf /etc/amnezia/amneziawg/awg0.conf
sudo awg-quick up awg0
ping -c 3 10.8.0.1
sudo awg show awg0
Our 24.04 client completed the handshake within seconds and showed the same parameter set as the server.

Phones and desktops need the AmneziaWG app (or Amnezia VPN) instead of the official WireGuard app; the wg-easy AmneziaWG page lists the clients per platform and the newer 3.0 and 3.1 parameters, which older AmneziaWG 2.x clients reject.
What happens when the module disappears
We then rebooted the server into 7.0.0-38, the kernel the module had failed to build for. wg-easy started without an error. modinfo amneziawg found nothing, and the container quietly fell back to wg-quick up wg0 on the plain wireguard module, as its docs promise. The UI looked identical. Testing both clients against that server showed what the fallback costs you:
- the plain WireGuard config: handshake, 0% packet loss
- the AmneziaWG config: 20 KiB sent, 0 B received, no handshake
- wg-easy logs: no warning line about the fallback
So a kernel update on a box running AmneziaWG can silently cut off every AmneziaWG client while the dashboard stays green. The OVERRIDE_AUTO_AWG variable accepts awg or wg to force a mode instead of autodetecting, but forcing awg without the module cannot work either. The real protection is the dkms status check before every reboot, plus the kernel hold set early, before a kernel the module can’t build for arrives.
Migrating from wg-easy v14 to v15
v15 is a rewrite and can’t read the v14 data directory in place, so the migration goes through the wg0.json backup file and the wizard’s import screen. We ran a v14 container on the 24.04 VM with two clients, phone01 and laptop02, then moved it. armv6 and armv7 hosts can’t make this move at all, according to the project’s migration notes. The order that worked:
- In the v14 UI, click Backup and keep the downloaded
wg0.json - Write down your v14 environment variables, since none carry over
- Stop v14 with
docker compose down, notstop - Deploy v15 as shown earlier, including the HTTPS step
- In the wizard, answer Yes to the existing-setup question and upload the file
- Re-enter Host and your other v14 settings in the Admin Panel
Step 3 frees UDP 51820 for the new container. Run it in the directory that holds your v14 compose file, because Compose only knows about the project in the current directory.
sudo docker compose down
The import screen takes the file and finishes the wizard in one step, skipping the host and port screen entirely. Keep that skipped screen in mind, because it causes the one real problem we hit in this migration.

Both clients came across with their original addresses, and the server and client public keys in v15 matched the ones in the v14 backup, so devices that were already set up kept working without a new config:

The client list looked right at this point, which is exactly why the next problem is easy to miss.
Client configs show “Endpoint = :51820” after the import
This is the step 6 trap. Because the import skips the host screen and WG_HOST is not part of wg0.json, the Host field was empty after migration, and a config downloaded at that moment contained Endpoint = :51820 with no address. Existing devices do not notice, since their configs already hold the old endpoint, but every new or re-downloaded client gets a broken file. Open Admin Panel, Config, type the same name you used for WG_HOST and save:

After saving, the same download read Endpoint = vpn.example.com:51820. Host is the one we saw break, but reading the import code at the release we tested, it only carries keys, the address range and clients across. Anything else you set through v14 variables goes back in by hand:
WG_PORT: Port under Config, listen port under InterfaceWG_DEFAULT_DNSandWG_ALLOWED_IPS: DNS and Allowed IPs under ConfigWG_MTUandWG_PERSISTENT_KEEPALIVE: the Advanced fields under Config
Our v14 test used the defaults for all of these, so only Host needed fixing. The official migration page says most of the old variables moved to the Admin Panel without saying where each one lands, so add them to your own runbook if you’re moving several servers.
Keeping wg-easy running
Updates within v15 are a pull and a recreate, because the :15 tag moves to each new minor release. Use up and down rather than start and stop, because the upstream docs warn that stop leaves state behind that breaks the next start.
cd /etc/docker/containers/wg-easy
sudo docker compose pull
sudo docker compose up -d
On our server the image was already current, so Compose found nothing to recreate and left the container running. Check the footer of the UI or the log banner afterwards to see which release you’re on, and read the release notes first when the major number is about to change:
Image ghcr.io/wg-easy/wg-easy:15 Pulling
Image ghcr.io/wg-easy/wg-easy:15 Pulled
Container wg-easy Running
There’s no PASSWORD_HASH to regenerate in v15 if someone forgets the admin password. The bundled CLI resets it inside the container, and on our test the old password got a 401 straight afterwards while the new one logged in:
cd /etc/docker/containers/wg-easy
sudo docker compose exec wg-easy cli db:admin:reset --password 'Another-Lab-Passw0rd-26'
The CLI confirms which account it changed, so you know it hit the right database before you hand the new password over. Clear your shell history afterwards if the password went in on the command line.
ℹ Setting new password for admin user...
✔ Successfully updated admin user 1 (mary)
One gap to close on an internet-facing UI: wg-easy itself does not rate-limit logins. Its Caddy tutorial recommends limiting /api/auth/password, /api/auth/verify-2fa and /cnf/* at the proxy or with a tool like CrowdSec, and setting TRUSTED_PROXIES to Caddy’s address so the security log records the real client IP. We didn’t configure either in this lab, so treat that page as the next step rather than something shown here.
Everything else that matters lives in the etc_wireguard volume, which holds the database with users, clients and keys. Include it in whatever backs up the host, and run sudo docker compose exec wg-easy cli clients:list after any restore to confirm the peers came back. If you’re comparing options, the ovpn-admin web UI for OpenVPN fills the same role for OpenVPN shops, and Tailscale removes the server from the picture when you can live with a coordination service. For a single VPN box a small team controls, wg-easy behind Caddy is the setup we’d keep, with AmneziaWG left off until the kernel module stops breaking on Ubuntu kernel updates.