wg-easy en Docker: WireGuard con interfaz web
Configura wg-easy con Docker Compose: puertos, NET_ADMIN, sysctls necesarios y códigos QR para conectar teléfonos a WireGuard sin editar peers por SSH.
Qué está construyendo
wg-easy es WireGuard con una interfaz web que se ejecuta como un único contenedor de Docker. Gestiona la interfaz de WireGuard por usted y añade una interfaz web para crear clientes. Cada cliente que cree obtiene un archivo de configuración y un código QR, de modo que un teléfono puede conectarse a la VPN apuntando con la cámara a la pantalla.
El túnel es WireGuard estándar. El módulo del kernel mueve los paquetes, por lo que el rendimiento es el mismo que en una configuración escrita manualmente. Lo que obtiene es la gestión del ciclo de vida de los clientes: añadir, deshabilitar y eliminar peers sin editar un archivo de configuración mediante SSH. Lo que pierde es el control directo de esa configuración. Ese es el tema de la configuración manual de WireGuard en un VPS.
Necesita un host Linux que pueda administrar, como un VPS KVM, con una dirección IP pública o un nombre de dominio que apunte a él, una CPU x86_64 o arm64, Docker Engine con el plugin 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. Como consecuencia, el contenedor no podrá activar la interfaz.
La versión 15 trasladó la configuración fuera del entorno
La mayoría de las guías que encontrará se escribieron para wg-easy 14. En esa versión, se definía WG_HOST con la dirección del servidor y PASSWORD_HASH con un hash bcrypt de la contraseña de administrador, ambos 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 trasladaron al panel de administración de la interfaz web.
Por tanto, WG_HOST y PASSWORD_HASH ya no tienen ningún efecto. Si copia un archivo compose antiguo, el contenedor se inicia, ignora esas líneas y después le pide crear una cuenta de administrador en el navegador. No es un error. Es el nuevo flujo de configuración.
En octubre de 2026, la etiqueta principal que 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 permite una reversión limpia. El cambio a 15 sustituyó el archivo JSON de v14 por una base de datos SQLite.
El archivo compose
Cree un directorio para el stack y escriba en él el archivo compose oficial. Es el archivo del proyecto upstream, con las líneas opcionales comentadas omitidas.
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 todos los clientes que cree. Estos datos se almacenan en la base de datos SQLite wg-easy.db. Haga una copia de seguridad de ese volumen. Si reconstruye el stack, perderá todos los peers. Si prefiere ver esos archivos en el sistema de archivos del host, sustitúyalo por un bind mount. Antes, lea la diferencia entre bind mounts y volúmenes con nombre, porque los permisos se comportan de forma diferente.
Por qué necesita NET_ADMIN, SYS_MODULE y los sysctl
Un contenedor no puede modificar la pila de red de forma predeterminada. Cada una de estas líneas elimina un bloqueo concreto.
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 termina 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 todavía 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, un cliente se conecta, el handshake se completa y después se descarta cada paquete destinado a Internet. Por eso ping 1.1.1.1 agota el tiempo de espera aunque la VPN parezca conectada.
net.ipv4.conf.all.src_valid_mark=1 es el ajuste que más suele sorprender. WireGuard marca sus propios paquetes salientes para que no se enruten de nuevo por el túnel. El filtrado estricto de la ruta inversa detecta un paquete cuya dirección de origen 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.
Inicie el servicio 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. start sólo reinicia los contenedores que ya existen, por lo que no aplica ningún cambio realizado en el archivo compose, incluidos los ajustes de red y de sysctl, mientras que up recrea todo lo que haya cambiado. Si quiere que la pila vuelva a iniciarse después de un reinicio, restart: unless-stopped ya se encarga de ello, y el comportamiento de arranque de los servicios compose explica qué garantiza y qué no garantiza esa política.
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 conectarse 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 entrega a un teléfono apunta a un destino inaccesible y el handshake nunca se completa.
Hay otro aspecto relacionado con ese puerto: wg-easy nunca sirve TLS por sí mismo y no rechaza el HTTP sin cifrar. Con http://, la interfaz sigue cargándose y muestra un banner de advertencia. Lo que cambia la opción predeterminada es la cookie de sesión, que lleva el atributo Secure, de modo que un navegador que accede a la interfaz mediante HTTP sin cifrar en una dirección pública la descarta y el inicio de sesión no se conserva. INSECURE=true elimina ese atributo y no hace nada más. Si coloca delante un reverse proxy que termine TLS, incluso con un certificado autofirmado, no necesita cambiar nada en wg-easy.
No publique el puerto de la interfaz en Internet
El archivo compose publica 51821 en todas las interfaces. Es una página de inicio de sesión para un sistema que puede enrutar su tráfico, por lo que no debe quedar abierta a todo Internet. Publicar un puerto en Docker escribe reglas en la cadena DOCKER, que se evalúa antes que ufw, de modo que una regla deny de ufw no lo cierra. Conviene entender este problema por separado; por qué los puertos publicados por Docker ignoran ufw lo explica en detalle.
La solución sencilla es enlazar la interfaz con 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 y ningún otro equipo puede acceder a ese puerto. INSECURE=true sólo elimina el atributo Secure de la cookie de sesión para que el navegador conserve el inicio de sesión en esta dirección HTTP sin cifrar. Esto es seguro aquí porque el tramo HTTP sin cifrar nunca sale de la interfaz loopback.
Abrir UDP 51820 y comprobar ambos firewalls
WireGuard necesita que UDP 51820 sea accesible desde Internet. Docker publica el puerto, pero muchos proveedores colocan un firewall de red independiente delante del VPS que Docker no puede controlar. 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 breve que escribir las reglas de 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 nunca activó la interfaz y sudo docker compose logs wg-easy indicará el motivo.
Cree un cliente y escanéelo en 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 libre del túnel y genera el par de claves automáticamente. 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, seleccione la opción para añadir un túnel desde un código QR y apunte la cámara al código que aparece en la pantalla. El túnel aparece con el nombre que introdujo. Actívelo. La fila del cliente en la interfaz empezará a mostrar los contadores de transferencia y la hora del último handshake. Cuando un teléfono está conectado al túnel, puede acceder a servicios que nunca se publicaron en Internet. Así, el teléfono puede seguir cargando fotos en un servidor de fotos autoalojado desde cualquier lugar sin que ese servidor tenga un solo puerto abierto al exterior. El mismo método sirve para los contenidos multimedia. Una biblioteca de Jellyfin reconstruida como un videoclub de los años 90 resulta cómoda de explorar desde la habitación de un hotel, mientras permanece tan privada como en la LAN. Las alertas funcionan en la dirección contraria mediante el mismo túnel. Un servidor ntfy autoalojado puede enviar un mensaje a ese teléfono en cuanto falle una tarea de copia de seguridad, sin responder nunca a una solicitud de Internet pública.
Un cliente que no muestra ningún handshake después de activarlo no está llegando al servidor. El problema suele estar en 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, suele indicar un problema de forwarding o de 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 dejar atrás la interfaz web
wg-easy es la herramienta adecuada mientras sus peers sean personas y teléfonos. La interfaz web es más rápida que editar archivos de configuración y revocar el acceso de un teléfono perdido requiere un solo clic.
Encontrará sus límites cuando necesite algo que la interfaz web no pueda modelar. El enrutamiento entre sitios, donde el AllowedIPs de un peer cubre toda una subred remota en lugar de una sola dirección, suele ser el primer límite. Los túneles divididos con reglas de enrutamiento por peer, o una configuración generada por su herramienta de aprovisionamiento, son el siguiente. En ese punto, la configuración escrita manualmente no es más difícil. Simplemente es diferente, y la guía de WireGuard estándar muestra el mismo túnel creado a partir de wg0.conf. Si prefiere dejar de ejecutar el plano de control, WireGuard comparado con Tailscale explica la opción gestionada. Que el cambio resulte adecuado depende de qué pueda alcanzar realmente el servidor de coordinación, y conviene leer el modelo de confianza de Tailscale antes de entregarle acceso a su red. El coste suele ser la siguiente cuestión. Lo que incluye realmente el plan gratuito de Tailscale es hasta seis usuarios sin coste, únicamente para uso no comercial. Por tanto, un hogar no paga nada, mientras que un equipo de una pequeña empresa no cumple los requisitos, aunque tenga pocos usuarios. Después de ese límite, la facturación cuenta usuarios en lugar de dispositivos. Es una estructura de costes distinta de la de un VPS que ya paga, por lo que debe consultar cuánto cuesta Tailscale cuando se supera el plan gratuito antes de migrar un equipo. El túnel completo que acaba de crear tiene allí un equivalente directo, porque anunciar el VPS como nodo de salida de Tailscale proporciona la misma ruta de salida a través del servidor. La aprobación se realiza en la consola de administración en lugar de escribirla en la configuración de cada cliente. La limitación de las subredes también tiene un equivalente, porque anunciar una red privada completa desde el VPS entrega esa red a todos los dispositivos del tailnet sin la edición de AllowedIPs por peer que le llevó a dejar atrás la interfaz web. Si quiere ese panel y el enrutamiento automático de malla, pero no el servidor de coordinación de otra empresa, ejecutar su propio servidor NetBird en un VPS mantiene el plano de control en hardware que posee. A cambio, tendrá que configurar DNS y TLS, algo que wg-easy nunca le exigió.
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 del 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 se reescribió y el proyecto 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 normalmente y después le solicita crear una cuenta de administrador 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 el kernel ya tiene WireGuard?
No. SYS_MODULE y el montaje /lib/modules existen para que el contenedor pueda cargar el módulo cuando el host no lo tenga. En un host donde sudo modprobe wireguard ya se ejecuta correctamente, la capacidad no se utiliza. Eliminarla es una medida de hardening razonable, y NET_ADMIN sigue siendo necesario 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á activado, 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 que ya no puede alcanzar, se comporta en el navegador exactamente como una conexión caída.
¿Cómo hago una copia de seguridad de mis clientes?
Todo se almacena en el volumen con nombre etc_wireguard. En la versión 15, esto significa una base de datos SQLite en /etc/wireguard/wg-easy.db dentro del contenedor. La documentación de v15 no describe ninguna función de copia de seguridad o exportación, así que haga una copia del volumen: ejecute docker compose down, copie el contenido del volumen a una ubicación externa al servidor y vuelva a iniciar la pila con docker compose up -d. Hágalo antes de cualquier actualización. El archivo wg0.json que mencionan las guías antiguas pertenece a wg-easy 14. Es el archivo que exporta el botón de copia de seguridad de v14 y, en v15, sólo se utiliza como archivo que se carga en el asistente de configuración al migrar una instalación antigua.
¿Puedo ejecutar wg-easy detrás de un reverse proxy?
Sí. Coloque el proxy delante del puerto de la interfaz web, termine TLS allí y deje INSECURE sin definir. El contenedor acepta directamente el tramo HTTP sin cifrar procedente del proxy, y los ejemplos oficiales para Caddy y Traefik no establecen ninguna variable INSECURE. Como el navegador se comunica mediante HTTPS con el proxy, debe conservar el atributo Secure de la cookie de sesión, que es lo único que elimina INSECURE=true. Mantenga UDP 51820 publicado directamente, porque el tráfico de la VPN usa UDP y no pasa por un proxy HTTP.