wg-easy con WireGuard y Docker Compose
Configura wg-easy con Docker Compose: puertos, NET_ADMIN, sysctls necesarios y códigos QR para conectar teléfonos a WireGuard sin editar archivos por SSH.
Qué va a crear
wg-easy es WireGuard con una interfaz web que se ejecuta como un solo contenedor de Docker. Administra la interfaz de WireGuard y añade una interfaz web para crear clientes. Cada cliente que crea obtiene un archivo de configuración y un código QR. De este modo, un teléfono se conecta a la VPN apuntando con la cámara a la pantalla.
El túnel utiliza WireGuard normal. El módulo del kernel mueve los paquetes, por lo que el rendimiento es el mismo que en una configuración escrita manualmente. La ventaja es la gestión del ciclo de vida de los clientes: puede añadir, deshabilitar y eliminar pares sin editar un archivo de configuración mediante SSH. La desventaja es que pierde el control directo de esa configuración. Este es el tema de la configuración manual de WireGuard en un VPS.
Necesita un VPS KVM con una dirección IPv4 pública, Docker Engine con el complemento Compose y acceso root. La virtualización de contenedores que comparte el kernel del host, como OpenVZ o LXC, normalmente no puede cargar el módulo de WireGuard. En ese caso, el contenedor no podrá activar la interfaz.
La versión 15 movió la configuración fuera del entorno
La mayoría de las guías disponibles se escribieron para wg-easy 14. En esa versión, se configuraba WG_HOST con la dirección del servidor y PASSWORD_HASH con un hash bcrypt de la contraseña de administrador, ambas como variables de entorno. La versión 15 es una reescritura. Las notas oficiales de migración indican claramente que v15 no usa las mismas variables de entorno que v14 y que la mayoría se trasladó al panel de administración de la interfaz web.
Por lo tanto, WG_HOST y PASSWORD_HASH ya no tienen ningún efecto. Si copia un archivo de composición antiguo, el contenedor se inicia, ignora esas líneas y después le solicita crear una cuenta de administrador en el navegador. No es un error. Es el nuevo flujo de configuración.
A fecha de julio de 2026, la etiqueta principal que se debe fijar es 15. Fije la versión principal en lugar de usar latest, porque una actualización de versión principal cambia el formato de configuración en disco y no se revierte correctamente.
El archivo de compose
Crea un directorio para el stack y escribe en él el archivo oficial de compose. Este es el archivo del proyecto upstream, sin modificar.
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.ymlEl contenido es el siguiente:
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::/64etc_wireguard es un volumen con nombre que contiene la clave del servidor y cada cliente que crees. Haz una copia de seguridad de ese volumen. De lo contrario, una reconstrucción eliminará todos tus peers. Si prefieres ver esos archivos en el sistema de archivos del host, sustitúyelo por un bind mount y lee la diferencia entre bind mounts y volúmenes con nombre antes de hacerlo, porque los permisos funcionan de forma diferente.
Por qué necesita NET_ADMIN, SYS_MODULE y los sysctls
Un contenedor no puede modificar la pila de red de forma predeterminada, y cada una de estas líneas elimina un bloqueo específico.
NET_ADMIN permite que el contenedor cree la interfaz wg0, le asigne una dirección y escriba rutas. Sin este permiso, el contenedor se inicia y después se detiene al activar la interfaz, porque ip link add wg0 type wireguard devuelve Operation not permitted.
SYS_MODULE, junto con el montaje de solo lectura de /lib/modules, permite que el contenedor cargue el módulo de kernel de WireGuard si el host aún no lo ha cargado. El módulo reside en el kernel del host, no dentro de la imagen. Por eso el directorio del host debe estar visible. En un kernel moderno, el módulo suele estar integrado. Puede confirmarlo con sudo modprobe wireguard && echo ok en el host.
net.ipv4.ip_forward=1 hace que el kernel reenvíe los paquetes que no están dirigidos al propio equipo. Sin este ajuste, el cliente se conecta y el handshake se completa, pero todos los paquetes dirigidos a Internet se descartan. Por eso ping 1.1.1.1 agota el tiempo de espera mientras la VPN parece estar conectada.
net.ipv4.conf.all.src_valid_mark=1 es el ajuste que más sorprende. WireGuard marca sus propios paquetes salientes para que no se enruten de nuevo por el túnel. El filtrado estricto de ruta inversa detecta que la dirección de origen de un paquete no coincide con la ruta esperada y lo descarta. Este sysctl indica al kernel que acepte los paquetes marcados. Así se evita que un túnel completo se interrumpa a sí mismo.
Inícielo y cree la cuenta de administrador
cd /etc/docker/containers/wg-easy
sudo docker compose up -d
sudo docker compose logs -fUse docker compose up y docker compose down, no start ni stop. El proyecto upstream advierte que ejecutar start en un contenedor creado con una configuración diferente deja la red en un estado incoherente. Si quiere que la pila vuelva a iniciarse después de un reinicio, restart: unless-stopped ya lo contempla, y el comportamiento de inicio de los servicios de compose explica qué garantiza esa política y qué no.
La interfaz web escucha en TCP 51821. En la primera visita muestra una página de configuración donde se crea la cuenta de administrador y se confirma la dirección del host que usarán los clientes para acceder al servidor. Esa dirección del host aparece en la línea Endpoint de la configuración de cada cliente, por lo que debe ser la IP pública o el nombre DNS del VPS. Si es incorrecta, el código QR que se entrega a un teléfono apunta a una dirección inaccesible y el handshake nunca se completa.
Hay otro aspecto relacionado con ese puerto: wg-easy 15 rechaza HTTP sin cifrado a menos que se establezca INSECURE=true. Es válido acceder mediante HTTPS con un certificado no confiable o terminar TLS en un proxy inverso situado delante. Acceder mediante http:// con la configuración predeterminada no es válido.
No publique el puerto de la interfaz de usuario en Internet
El archivo compose publica 51821 en todas las interfaces. Esa es una página de inicio de sesión para un equipo que puede enrutar su tráfico y no debería estar abierta a todo Internet. Publicar un puerto en Docker escribe reglas en la cadena DOCKER, que se evalúa antes que ufw. Por eso, una regla de denegación de ufw no lo cierra. Este problema merece entenderse por separado, y por qué los puertos publicados por Docker ignoran ufw lo explica en detalle.
La solución sencilla es enlazar la interfaz de usuario a loopback y acceder a ella mediante un túnel SSH:
ports:
- "51820:51820/udp"
- "127.0.0.1:51821:51821/tcp"
environment:
- INSECURE=trueDespués, desde su portátil:
ssh -L 51821:127.0.0.1:51821 youruser@your.server.addressAbra http://127.0.0.1:51821 en el navegador de su portátil. SSH cifra el tráfico, el puerto no responde a nadie más y INSECURE=true es seguro aquí porque el salto HTTP sin cifrar nunca sale de la interfaz loopback.
Abra UDP 51820 y compruebe ambos firewalls
WireGuard necesita que UDP 51820 sea accesible desde Internet. Docker publica este puerto, pero muchos proveedores colocan un firewall de red independiente delante del VPS que Docker no conoce. Abra el puerto en ambos lugares. Si administra el firewall del host con ufw, las reglas básicas de ufw para un VPS son una opción más sencilla que escribir nftables manualmente.
Compruebe que el contenedor está escuchando realmente:
sudo ss -ulnp | grep 51820Debería ver un socket UDP en estado de escucha. Si no aparece nada en esa línea, el contenedor no activó la interfaz y sudo docker compose logs wg-easy indicará el motivo.
Crear un cliente y escanearlo con un teléfono
En la interfaz, cree un cliente y asígnele un nombre que pueda reconocer más adelante, como el dispositivo al que pertenece. wg-easy asigna la siguiente dirección de túnel disponible y genera el par de claves. Cada fila de cliente ofrece un código QR y un archivo .conf descargable.
Instale la aplicación oficial de WireGuard en el teléfono, elija añadir un túnel desde un código QR y apunte la cámara al código de la pantalla. El túnel aparece con el nombre que escribió. Actívelo y la fila del cliente en la interfaz empezará a mostrar los contadores de transferencia y la hora del handshake más reciente.
Un cliente que no muestra ningún handshake después de activarlo no está llegando al servidor. Esto apunta al puerto UDP 51820, ya sea en el firewall del proveedor o en la dirección del endpoint incluida en la configuración. Un cliente que muestra un handshake, pero no tiene acceso funcional a Internet, apunta al reenvío o al DNS.
En un equipo de escritorio, descargue el archivo .conf e impórtelo en el cliente de WireGuard en lugar de volver a escribirlo. La clave privada de ese archivo se genera una sola vez y se muestra una sola vez. Trate el archivo como trataría una clave privada de SSH.
Cuándo superar la interfaz
wg-easy es la herramienta adecuada mientras tus pares sean personas y teléfonos. La interfaz es más rápida que editar archivos de configuración, y revocar un teléfono perdido requiere un solo clic.
Sus límites aparecen cuando necesitas algo que la interfaz no puede representar. El enrutamiento de sitio a sitio, donde el AllowedIPs de un par cubre toda una subred remota en lugar de una sola dirección, suele ser el primer obstáculo. Los túneles divididos con reglas de enrutamiento por par, o una configuración generada por tu herramienta de aprovisionamiento, son los siguientes. En ese punto, la configuración escrita manualmente no es más difícil; simplemente es diferente. La guía básica de WireGuard muestra el mismo túnel creado con wg0.conf. Si prefieres dejar de ejecutar el plano de control, WireGuard comparado con Tailscale explica la opción administrada.
Si la sintaxis de Compose anterior era la parte desconocida, y no WireGuard, Conceptos básicos de Docker Compose en un VPS explica el formato de archivo y los comandos habituales.
FAQ
¿Por qué wg-easy ignora WG_HOST y PASSWORD_HASH?
Esas variables pertenecen a wg-easy 14. La versión 15 es una reescritura y el proyecto upstream trasladó casi toda la configuración al panel de administración de la interfaz web. El contenedor no lee ninguna de las dos variables, por lo que se inicia con normalidad y luego solicita crear una cuenta de administración en la primera visita. Establezca la dirección del host que usarán los clientes en esa página de configuración.
¿Necesito SYS_MODULE si mi kernel ya tiene WireGuard?
No. Los montajes SYS_MODULE y /lib/modules permiten que el contenedor cargue el módulo cuando el host no lo tiene. En un host donde sudo modprobe wireguard ya se ejecuta correctamente, esta capacidad no se utiliza. Eliminarlos es una medida de hardening razonable, y NET_ADMIN sigue siendo obligatorio en cualquier caso.
El cliente se conecta, pero no hay Internet. ¿Cuál es el problema?
Un handshake sin tráfico casi siempre indica un problema de forwarding. Confirme que net.ipv4.ip_forward=1 y net.ipv4.conf.all.src_valid_mark=1 sigan en el archivo compose, porque una copia editada manualmente suele perderlos. Si el forwarding está habilitado, compruebe el servidor DNS que recibió el cliente. Un túnel que envía todo el tráfico a través de la VPN, pero apunta a un servidor DNS al que ya no puede acceder, se comporta exactamente como una conexión caída en un navegador.
¿Cómo hago una copia de seguridad de mis clientes?
Todo se almacena en el volumen con nombre etc_wireguard, en un archivo wg0.json. La interfaz también tiene un botón de copia de seguridad que exporta los mismos datos. Copie ese archivo a una ubicación externa al servidor antes de cualquier actualización. La restauración se realiza mediante una carga durante el paso de configuración de un contenedor nuevo.
¿Puedo ejecutar wg-easy detrás de un proxy inverso?
Sí. Coloque el proxy delante de TCP 51821, termine TLS allí y establezca INSECURE=true en el contenedor para que acepte el salto HTTP sin cifrar desde el proxy. Mantenga UDP 51820 publicado directamente, porque el tráfico de la VPN usa UDP y no pasa por un proxy HTTP.