wg-easy: WireGuard con interfaccia web in Docker
Configura wg-easy con Docker Compose: porte, NET_ADMIN, sysctl necessari e codici QR per collegare gli smartphone alla VPN senza modificare file via SSH.
Cosa stai costruendo
wg-easy è WireGuard con un'interfaccia web, eseguito in un unico container Docker. Gestisce per te l'interfaccia WireGuard e aggiunge un'interfaccia browser per creare i client. Ogni client creato riceve un file di configurazione e un codice QR, quindi un telefono può collegarsi alla VPN inquadrando lo schermo con la fotocamera.
Il tunnel è il normale WireGuard. Il modulo del kernel trasferisce i pacchetti, quindi il throughput è lo stesso di una configurazione scritta manualmente. Il vantaggio riguarda il ciclo di vita dei client: puoi aggiungere, disabilitare ed eliminare peer senza modificare un file di configurazione tramite SSH. Lo svantaggio è rinunciare al controllo diretto di quel file, argomento trattato in configurazione manuale di WireGuard su un VPS.
Serve un host Linux che puoi amministrare, ad esempio un VPS KVM, con un indirizzo IP pubblico o un nome di dominio che punti a tale host, una CPU x86_64 o arm64, Docker Engine con il plugin Compose e accesso root. La virtualizzazione dei container che condivide il kernel dell'host, come OpenVZ o LXC, di norma non può caricare il modulo WireGuard e il container non riuscirà ad attivare l'interfaccia.
La versione 15 ha spostato le impostazioni fuori dall'ambiente
La maggior parte delle guide disponibili è stata scritta per wg-easy 14, dove si impostava WG_HOST sull'indirizzo del server e PASSWORD_HASH su un hash bcrypt della password di amministrazione, entrambi come variabili d'ambiente. La versione 15 è una riscrittura. Le note ufficiali sulla migrazione indicano chiaramente che v15 non usa le stesse variabili d'ambiente di v14 e che la maggior parte di queste impostazioni è stata spostata nel pannello di amministrazione dell'interfaccia web.
Di conseguenza, WG_HOST e PASSWORD_HASH non hanno più alcun effetto. Se si copia un vecchio file compose, il container si avvia, ignora quelle righe e chiede quindi di creare un account di amministrazione nel browser. Non è un bug. È il nuovo flusso di configurazione.
A ottobre 2026, il tag major da fissare è 15. Fissa la versione major invece di usare latest, perché un aggiornamento major modifica il formato della configurazione su disco e non consente un rollback corretto. Il passaggio alla versione 15 ha sostituito il file JSON di v14 con un database SQLite.
Il file compose
Crea una directory per lo stack e inserisci al suo interno il file compose ufficiale. Questo è il file upstream, senza le righe opzionali commentate.
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.ymlIl contenuto è il seguente:
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 è un volume denominato che contiene la chiave del server e tutti i client creati, archiviati nel database SQLite wg-easy.db. Esegui il backup di questo volume, altrimenti una ricostruzione elimina tutti i peer. Se preferisci vedere questi file nel filesystem dell'host, sostituisci il volume con un bind mount e leggi la differenza tra bind mount e volumi denominati prima di procedere, perché i permessi vengono gestiti in modo diverso.
Perché sono necessari NET_ADMIN, SYS_MODULE e i sysctl
Per impostazione predefinita, un container non può modificare lo stack di rete e ciascuna di queste righe rimuove un blocco specifico.
NET_ADMIN consente al container di creare l'interfaccia wg0, assegnarle un indirizzo e scrivere le route. Senza questa opzione, il container si avvia e poi si arresta durante l'attivazione dell'interfaccia, perché ip link add wg0 type wireguard restituisce Operation not permitted.
SYS_MODULE, insieme al mount in sola lettura /lib/modules, consente al container di caricare il modulo kernel WireGuard se l'host non lo ha già caricato. Il modulo risiede nel kernel dell'host, non nell'immagine; per questo la directory dell'host deve essere visibile. Nei kernel moderni il modulo è in genere compilato direttamente nel kernel. Puoi verificarlo eseguendo sudo modprobe wireguard && echo ok sull'host.
net.ipv4.ip_forward=1 indica al kernel di inoltrare i pacchetti che non sono destinati al sistema stesso. Senza questa impostazione, il client si connette, l'handshake ha esito positivo, ma ogni pacchetto diretto verso Internet viene scartato. Di conseguenza, ping 1.1.1.1 va in timeout mentre la VPN risulta connessa.
net.ipv4.conf.all.src_valid_mark=1 è l'impostazione che sorprende di più. WireGuard contrassegna i propri pacchetti in uscita per impedire che vengano instradati nuovamente nel tunnel. Il reverse path filtering in modalità strict rileva un pacchetto il cui indirizzo sorgente non corrisponde alla route prevista e lo scarta. Questo sysctl indica al kernel di accettare i pacchetti contrassegnati e impedisce che un full tunnel interrompa il proprio traffico.
Avviare il servizio e creare l'account amministratore
cd /etc/docker/containers/wg-easy
sudo docker compose up -d
sudo docker compose logs -fUsare docker compose up e docker compose down, non start e stop. start riavvia soltanto i container già esistenti, quindi non applica le modifiche apportate al file compose, comprese le impostazioni di rete e sysctl, mentre up ricrea ciò che è cambiato. Se si vuole ripristinare lo stack dopo un riavvio, restart: unless-stopped lo gestisce già, e il comportamento all'avvio dei servizi compose spiega cosa questa policy garantisce e cosa non garantisce.
L'interfaccia web è in ascolto sulla porta TCP 51821. Alla prima visita viene mostrata una pagina di configurazione in cui creare l'account amministratore e confermare l'indirizzo host che i client useranno per raggiungere il server. Questo indirizzo host viene inserito nella riga Endpoint di ogni configurazione client, quindi deve essere l'IP pubblico o il nome DNS del VPS. Se è errato, il codice QR fornito a un telefono punta a una destinazione irraggiungibile e l'handshake non viene mai completato.
Un altro aspetto riguarda questa porta: wg-easy non gestisce direttamente TLS e non rifiuta le connessioni HTTP non cifrate. Con http:// l'interfaccia continua a essere caricata e mostra un banner di avviso. La modifica riguarda il cookie di sessione, che contiene il flag Secure: un browser che raggiunge l'interfaccia tramite HTTP non cifrato su un indirizzo pubblico lo scarta e il login non viene mantenuto. INSECURE=true rimuove quel flag e non esegue altre modifiche. Se si configura davanti un reverse proxy che termina TLS, anche con un certificato autofirmato, non è necessaria alcuna modifica a wg-easy.
Non pubblicare la porta dell'interfaccia su Internet
Il file compose pubblica 51821 su tutte le interfacce. Si tratta di una pagina di accesso a un host che può instradare il traffico, quindi non deve essere esposta a chiunque. La pubblicazione di una porta in Docker scrive regole nella catena DOCKER, valutata prima di ufw; di conseguenza, una regola ufw deny non chiude la porta. Questo comportamento merita di essere compreso separatamente e il motivo per cui le porte pubblicate da Docker ignorano ufw lo descrive in dettaglio.
La soluzione più semplice consiste nel collegare l'interfaccia al loopback e raggiungerla tramite un tunnel SSH:
ports:
- "51820:51820/udp"
- "127.0.0.1:51821:51821/tcp"
environment:
- INSECURE=trueQuindi, dal laptop:
ssh -L 51821:127.0.0.1:51821 youruser@your.server.addressAprire http://127.0.0.1:51821 nel browser del laptop. Il traffico è cifrato da SSH e la porta non risponde a nessun altro host. INSECURE=true rimuove soltanto il flag Secure dal cookie di sessione, così il browser conserva il login su questo indirizzo HTTP non cifrato. In questo caso è sicuro, perché il tratto HTTP non cifrato non esce mai dall'interfaccia di loopback.
Apri la porta UDP 51820 e controlla entrambi i firewall
WireGuard deve poter raggiungere la porta UDP 51820 da Internet. Docker pubblica la porta, ma molti provider collocano un firewall di rete separato davanti al VPS, che Docker non può gestire. Apri la porta in entrambi i punti. Se gestisci il firewall dell'host con ufw, le regole ufw di base per un VPS sono la soluzione più rapida rispetto alla scrittura manuale delle regole nftables.
Verifica che il container sia effettivamente in ascolto:
sudo ss -ulnp | grep 51820Dovresti vedere un socket UDP in ascolto. Se la riga non mostra nulla, il container non ha mai attivato l'interfaccia e sudo docker compose logs wg-easy indicherà il motivo.
Creare un client e configurarlo su un telefono
Nell'interfaccia creare un client e assegnargli un nome che sia possibile riconoscere in seguito, ad esempio quello del dispositivo a cui appartiene. wg-easy assegna il successivo indirizzo libero del tunnel e genera automaticamente la coppia di chiavi. Ogni riga del client include un codice QR e un file .conf scaricabile.
Installare l'app ufficiale WireGuard sul telefono, scegliere di aggiungere un tunnel tramite codice QR e inquadrare con la fotocamera il codice visualizzato sullo schermo. Il tunnel viene mostrato con il nome inserito. Attivarlo: nella riga del client dell'interfaccia iniziano a comparire i contatori del traffico trasferito e l'orario dell'handshake più recente. Quando un telefono è connesso al tunnel, può raggiungere servizi mai pubblicati su Internet. In questo modo il telefono può continuare a caricare foto su un server fotografico self-hosted da qualsiasi luogo, senza che quel server debba esporre una sola porta verso Internet. Lo stesso approccio è utile per i contenuti multimediali: una libreria Jellyfin ricostruita come un videonoleggio degli anni 90 è piacevole da consultare dalla camera di un hotel, pur restando privata come quando si trova sulla LAN. Gli avvisi funzionano anche nella direzione opposta sullo stesso tunnel: un server ntfy self-hosted può inviare un messaggio al telefono appena un job di backup non riesce, senza dover mai rispondere a una richiesta proveniente da Internet.
Un client che non mostra alcun handshake dopo l'attivazione non sta raggiungendo affatto il server. Il problema riguarda probabilmente UDP 51820, a livello del firewall del provider oppure dell'indirizzo dell'endpoint incorporato nella configurazione. Un client che mostra un handshake ma non ha accesso a Internet indica invece un problema di forwarding o DNS.
Su un desktop, scaricare il file .conf e importarlo nel client WireGuard invece di reinserirne il contenuto manualmente. La chiave privata contenuta nel file viene generata una sola volta e visualizzata una sola volta. Proteggere il file come si proteggerebbe una chiave privata SSH.
Quando l'interfaccia non è più sufficiente
wg-easy è lo strumento giusto finché i tuoi peer sono persone e telefoni. L'interfaccia è più rapida della modifica dei file di configurazione e la revoca di un telefono smarrito richiede un solo clic.
I suoi limiti emergono quando vuoi qualcosa che l'interfaccia non rappresenta. Il routing site-to-site, in cui il AllowedIPs di un peer copre un'intera subnet remota anziché un singolo indirizzo, è il primo ostacolo tipico. Seguono i tunnel divisi con regole di routing per peer o una configurazione generata dal tuo strumento di provisioning. A quel punto la configurazione manuale non è più difficile: è semplicemente diversa, e la guida al normale WireGuard mostra lo stesso tunnel costruito a partire da wg0.conf. Se preferisci smettere del tutto di eseguire il piano di controllo, WireGuard a confronto con Tailscale illustra l'opzione gestita. La convenienza di questo compromesso dipende da ciò che il server di coordinamento può effettivamente raggiungere, e conviene leggere il modello di attendibilità di Tailscale prima di affidargli la rete. Il costo è di solito la domanda successiva, e cosa copre realmente il piano gratuito di Tailscale prevede fino a sei utenti senza costi, solo per uso non commerciale: una famiglia non paga nulla, mentre il team di una piccola azienda non è idoneo, indipendentemente dal numero ridotto di persone. Oltre quel limite, la fatturazione considera gli utenti e non i dispositivi, quindi ha una struttura diversa rispetto a un VPS che già paghi; per questo quanto costa Tailscale dopo il superamento del piano gratuito è il dato da verificare prima di migrare un team. Il tunnel completo appena creato ha un equivalente diretto anche in Tailscale: pubblicare il VPS come exit node di Tailscale offre la stessa uscita attraverso il server, approvata nella console di amministrazione invece di essere scritta nella configurazione di ciascun client. Anche la subnet ha un equivalente, perché pubblicare un'intera rete privata dal VPS assegna quella rete a ogni dispositivo del tailnet senza la modifica di AllowedIPs per singolo peer che ti aveva spinto oltre l'interfaccia. Se vuoi quel dashboard e il routing mesh automatico, ma non vuoi un server di coordinamento di terzi, eseguire il proprio server NetBird su un VPS mantiene il piano di controllo su hardware che possiedi, al costo della configurazione DNS e TLS che wg-easy non richiedeva.
Se la sintassi di Compose precedente era la parte poco familiare, anziché WireGuard, nozioni di base su Docker Compose in un VPS spiega il formato del file e i comandi usati ogni giorno.
FAQ
Perché wg-easy ignora WG_HOST e PASSWORD_HASH?
Queste variabili appartengono a wg-easy 14. La versione 15 è stata riscritta e il progetto upstream ha spostato quasi tutta la configurazione nel pannello amministrativo dell'interfaccia web. Il container non legge nessuna delle due variabili, quindi si avvia normalmente e poi chiede di creare un account amministratore alla prima visita. Impostare invece l'indirizzo host usato dai client nella pagina di configurazione iniziale.
È necessario SYS_MODULE se il kernel dispone già di WireGuard?
No. SYS_MODULE e il mount /lib/modules servono a consentire al container di caricare il modulo quando l'host non lo ha. Su un host in cui sudo modprobe wireguard ha già esito positivo, questa capability non viene utilizzata. Rimuoverla è una misura di hardening ragionevole, mentre NET_ADMIN resta comunque necessario.
Il client si connette, ma Internet non è disponibile. Qual è il problema?
Un handshake senza traffico indica quasi sempre un problema di forwarding. Verificare che net.ipv4.ip_forward=1 e net.ipv4.conf.all.src_valid_mark=1 siano ancora presenti nel file compose, perché una copia modificata manualmente spesso li perde. Se il forwarding è attivo, controllare il server DNS ricevuto dal client. Un tunnel che invia tutto il traffico attraverso la VPN, ma usa un server DNS che non può più raggiungere, appare in un browser esattamente come una connessione non funzionante.
Come posso eseguire il backup dei client?
Tutto risiede nel volume denominato etc_wireguard. Nella versione 15 questo significa un database SQLite in /etc/wireguard/wg-easy.db all'interno del container. La documentazione di v15 non descrive alcuna funzione di backup o esportazione, quindi occorre eseguire il backup del volume stesso: eseguire docker compose down, copiare il contenuto del volume in una posizione esterna al server, quindi riavviare lo stack con docker compose up -d. Eseguire questa procedura prima di qualsiasi aggiornamento. Il file wg0.json citato dalle guide meno recenti appartiene a wg-easy 14. È il file esportato dal pulsante di backup di v14 e, nella v15, serve soltanto come file da caricare nella procedura guidata di configurazione quando si esegue la migrazione di un'installazione precedente.
Posso eseguire wg-easy dietro un reverse proxy?
Sì. Posizionare il proxy davanti alla porta dell'interfaccia web, terminare TLS sul proxy e lasciare INSECURE non impostato. Il container accetta il collegamento HTTP non cifrato proveniente dal proxy senza ulteriori modifiche, e gli esempi ufficiali per Caddy e Traefik non impostano affatto la variabile INSECURE. Poiché il browser comunica in HTTPS con il proxy, il cookie di sessione deve mantenere il flag Secure, che è l'unica cosa rimossa da INSECURE=true. Lasciare UDP 51820 pubblicato direttamente, perché il traffico VPN usa UDP e non passa attraverso un proxy HTTP.