22 March 2026
📌 Updated wg-easy tonight. Twenty-two months of accumulated bit rot, compressed into a thirty-minute session. Kreator in the headphones throughout.
The trigger was Termix - a web-based SSH management platform I was testing on the same Portainer instance. Before doing anything new, the obvious: the VPN container running next to it hadn't been touched in almost two years.
from docker run to compose
The original container had no compose file, no labels, nothing. Created by hand with docker run, the parameters lived only in the container inspect output. First step was extracting everything:
docker inspect wg-easy | grep -A 50 '"Env"'
docker inspect wg-easy --format='{{.Config.Cmd}}'
Then writing a proper compose file. The data was safe - bind mount to /root/.wg-easy on the host, not a named volume. docker rm doesn't touch host paths.
cp -a /root/.wg-easy /root/.wg-easy.bak
Paranoia is not optional when the thing you're updating is the VPN you use to reach everything else.
the password problem
Pulled latest, container came up healthy. Web UI accessible. Then: the old password didn't work.
Breaking change between versions - PASSWORD in plaintext is deprecated, PASSWORD_HASH with bcrypt is required. The wgpw helper tool mentioned in the docs doesn't exist in the latest image:
Error: Cannot find module '/app/wgpw'
The working method, using the image itself as the bcrypt environment:
docker run --rm -it ghcr.io/wg-easy/wg-easy node -e \
'const bcrypt = require("bcryptjs"); \
const hash = bcrypt.hashSync("YOUR_PASSWORD", 10); \
console.log(hash.replace(/\$/g, "$$$$"));'
The replace is not optional. Every $ in the hash must be escaped as $$ inside a compose environment block, otherwise it gets interpreted as a variable substitution and the hash arrives corrupted in the container.
One more detail: a commented-out PASSWORD= line in the compose file is enough to break authentication in some versions. Remove it entirely, not just comment it.
and then...
VPN stayed up throughout. WireGuard peers reconnected without touching wg0.conf or wg0.json. The bind mount did exactly what it was supposed to do.
The WG_HOST pointing to a dead .ninja domain was replaced with the static public IP. Clients already configured don't care - the host value only affects newly generated configs.