Tutoriais

WireGuard

Tutorial completo de configuração de VPN com WireGuard (vanilla e wg-easy) — peers, split tunnel, site-to-site, hardening e troubleshooting.

Tutorial prático cobrindo WireGuard “vanilla” instalado direto no host e a alternativa via container wg-easy, com comparação entre as duas abordagens e exemplos alinhados a um servidor de referência (VPS Debian 13, IP 203.0.113.10, docker network proxy 172.28.0.0/16).


1. Visão geral

WireGuard é uma VPN moderna que roda em userspace ou (preferencialmente) como módulo do kernel Linux a partir da versão 5.6. O design é deliberadamente minimalista: apenas ~4 mil linhas de código no kernel, criptografia fixa (Curve25519, ChaCha20, Poly1305, BLAKE2s, SipHash24, HKDF), sem negociação de algoritmos.

Vantagens contra OpenVPN e IPsec

  • Configuração declarativa em arquivo .conf curto (uma seção [Interface], uma [Peer] por par).
  • Performance: throughput próximo do link físico em hardware moderno, latência menor que OpenVPN (que roda em TLS sobre TCP/UDP em userspace).
  • Roaming nativo: o cliente pode mudar de Wi-Fi para 4G sem reabrir o túnel; basta o handshake estar válido (a cada 2 minutos).
  • Sem CA, sem cipher suite negotiation, sem PKI complexa. Cada peer é identificado por uma chave pública Curve25519.
  • Stealth razoável: sem resposta a pacotes inválidos, o daemon não revela presença para scanners não autenticados.

Quando NÃO usar

  • Cenários que precisam de autenticação por usuário/senha integrada com AD/LDAP. WireGuard só conhece chaves; autenticação humana fica por conta de camadas externas (ou de soluções como wg-easy, NetBird, Tailscale).
  • Quando o requisito é TCP-only (proxies corporativos que bloqueiam UDP). WireGuard é UDP puro; tunelar via TCP exige workarounds (udp2raw, wstunnel).
  • IPs dinâmicos sem endpoint discovery: WireGuard estático precisa de pelo menos um lado com endpoint conhecido. Em redes peer-to-peer dinâmicas, usar Tailscale/Headscale por cima.

2. Arquitetura de referência

flowchart LR
    laptop["Laptop<br/>peer 10.8.0.2"]
    phone["Celular<br/>peer 10.8.0.3"]
    internet((Internet))
    vps["VPS Debian 13<br/>203.0.113.10"]
    wgeasy["Container wg-easy<br/>172.28.0.12<br/>UDP 51820 / TCP 51821"]
    bridge["docker bridge proxy<br/>172.28.0.0/16"]
    app1["app-a<br/>172.28.0.x:3000"]
    app2["app-b<br/>172.28.0.x:8080"]
    db["postgres<br/>172.28.0.x:5432"]
    storage["object-storage<br/>172.28.0.x:9000"]

    laptop -->|UDP 51820| internet
    phone -->|UDP 51820| internet
    internet -->|porta 51820/udp publicada| vps
    vps --> wgeasy
    wgeasy -->|MASQUERADE: src 10.8.0.x reescrito p/ 172.28.0.12| bridge
    bridge --> app1
    bridge --> app2
    bridge --> db
    bridge --> storage

Ponto chave: o tráfego que entra com origem 10.8.0.0/24 (subnet dos peers WireGuard) sai do container com SNAT/MASQUERADE aplicado pela própria iptables que o wg-easy configura via cap_add: NET_ADMIN. Para os outros containers da proxy, todo cliente VPN aparece como 172.28.0.12. Isso simplifica allowlists do nginx (allow 172.28.0.1; deny all;) mas impede granularidade por peer no nível dos serviços downstream.


3. Conceitos

TermoSignificado
PeerCada ponta do túnel. Servidor é um peer; cada cliente é outro peer. WireGuard não tem hierarquia client/server, é simétrico — o que distingue é quem tem Endpoint configurado.
PrivateKeyChave privada Curve25519 do peer local. Fica em /etc/wireguard/privatekey ou inline em [Interface]. Nunca sai da máquina.
PublicKeyDerivada da PrivateKey, é o que se compartilha. Identifica o peer no [Peer] da outra ponta.
AllowedIPsDupla função: (1) crypto-routing — define quais IPs de destino são roteados para esse peer pelo wg-quick; (2) ACL de entrada — pacotes recebidos desse peer com source IP fora dessa lista são descartados.
EndpointIP:porta UDP onde o peer remoto escuta. Opcional num lado (o que está atrás de NAT/IP dinâmico).
PersistentKeepaliveIntervalo em segundos para enviar pacote vazio e manter o mapping NAT vivo. 25 é o valor recomendado quando o peer está atrás de NAT.
HandshakeTroca Noise_IK a cada 2 minutos (ou na primeira mensagem). Sem handshake recente, o túnel está morto mesmo que wg show liste o peer.
MTUDefault 1420 (IPv4 em link Ethernet). Cai para 1380 quando o caminho passa por PPPoE (fibra residencial brasileira). Se vir Destination Unreachable: Fragmentation Needed, é MTU.

Handshake — sequência simplificada

sequenceDiagram
    participant C as Cliente (initiator)
    participant S as Servidor (responder)
    C->>S: Handshake Initiation (ephemeral pub, encrypted static pub, timestamp, MAC)
    S->>C: Handshake Response (ephemeral pub, encrypted empty, MAC)
    Note over C,S: Sessao simetrica derivada (ChaCha20-Poly1305)
    C->>S: Data packet (n=0)
    S->>C: Data packet (n=0)
    Note over C,S: A cada 2 minutos ou apos REKEY_AFTER_MESSAGES, novo handshake

4. Pré-requisitos

  • Debian 13 (Trixie) ou Ubuntu 24.04+. Kernel >= 5.6 traz o módulo wireguard nativo — nenhum DKMS necessário.
  • Verificar:
uname -r                 # kernel >= 5.6
modinfo wireguard        # deve listar o modulo
  • Porta UDP 51820 aberta na firewall do provedor (no caso da VPS) e no roteador doméstico se for site-to-site reverso.
  • IPv4 público estático (ou IPv6 público — WireGuard suporta ambos sem distinção).
  • iptables ou nftables instalado para MASQUERADE (vanilla). Container já traz tudo embarcado.

5. Opção A — WireGuard vanilla no host

5.1 Instalação

sudo apt update
sudo apt install -y wireguard wireguard-tools

5.2 Habilitar IP forwarding

sudo tee /etc/sysctl.d/99-wireguard.conf <<'EOF'
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF

sudo sysctl --system

Conferir:

sysctl net.ipv4.ip_forward     # deve retornar 1

5.3 Gerar chaves do servidor

sudo mkdir -p /etc/wireguard
sudo chmod 700 /etc/wireguard
cd /etc/wireguard
umask 077
wg genkey | sudo tee privatekey | wg pubkey | sudo tee publickey

Resultado: dois arquivos com permissão 600. A privatekey será referenciada no wg0.conf.

5.4 Servidor — /etc/wireguard/wg0.conf

Identifique a interface externa (geralmente eth0 numa VPS):

ip route get 1.1.1.1 | awk '{print $5; exit}'
[Interface]
Address = 10.0.0.1/24
ListenPort = 51820
PrivateKey = <CONTEUDO_DE_/etc/wireguard/privatekey>
SaveConfig = false

PostUp   = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -s 10.0.0.0/24 -o eth0 -j MASQUERADE

[Peer]
# laptop
PublicKey = <PUBLICKEY_DO_CLIENTE_LAPTOP>
AllowedIPs = 10.0.0.2/32

[Peer]
# celular
PublicKey = <PUBLICKEY_DO_CLIENTE_CELULAR>
AllowedIPs = 10.0.0.3/32

SaveConfig = false é importante: com true, qualquer wg set em runtime sobrescreve o arquivo na hora do wg-quick down, atrapalhando versionamento.

5.5 Subir o túnel

sudo wg-quick up wg0
sudo wg show

Saída esperada de wg show:

interface: wg0
  public key: <server-pub>
  private key: (hidden)
  listening port: 51820

peer: <client-pub>
  allowed ips: 10.0.0.2/32

5.6 Persistir via systemd

sudo systemctl enable --now wg-quick@wg0
sudo systemctl status wg-quick@wg0

A unit wg-quick@.service já vem com o pacote wireguard-tools. O @wg0 casa com /etc/wireguard/wg0.conf.

5.7 Cliente — wg0.conf

Gerar chaves no cliente:

umask 077
wg genkey | tee client-priv | wg pubkey > client-pub
[Interface]
Address = 10.0.0.2/24
PrivateKey = <CLIENT_PRIVATE_KEY>
DNS = 1.1.1.1

[Peer]
PublicKey = <SERVER_PUBLIC_KEY>
Endpoint = 203.0.113.10:51820
AllowedIPs = 10.0.0.0/24, 172.28.0.0/16
PersistentKeepalive = 25

AllowedIPs define o split tunnel — ver seção 9. Adicionar o client-pub no [Peer] do wg0.conf do servidor e recarregar:

sudo wg syncconf wg0 <(wg-quick strip wg0)

(syncconf não derruba o túnel; wg-quick down/up derrubaria.)


6. Opção B — wg-easy via docker compose

wg-easy empacota WireGuard + UI web em Node.js + iptables auto-configurado num único container. Útil quando o objetivo é gerenciar peers sem editar .conf na mão.

6.1 docker-compose.yml

Alinhado ao setup descrito (container fixo em 172.28.0.12, network proxy já existente):

services:
  wg-easy:
    image: ghcr.io/wg-easy/wg-easy:14
    container_name: wg-easy
    restart: unless-stopped
    environment:
      - WG_HOST=203.0.113.10
      - PASSWORD_HASH=$$2a$$12$$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123
      - WG_PORT=51820
      - WG_DEFAULT_ADDRESS=10.8.0.x
      - WG_DEFAULT_DNS=1.1.1.1
      - WG_ALLOWED_IPS=10.8.0.0/24,172.28.0.0/16
      - WG_PERSISTENT_KEEPALIVE=25
      - WG_MTU=1420
      - UI_TRAFFIC_STATS=true
      - LANG=pt
      # Opcional: rotear DNS interno do ambiente
      # - WG_POST_UP=iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE
      # - WG_POST_DOWN=iptables -t nat -D POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE
    volumes:
      - ./data:/etc/wireguard
    ports:
      - "51820:51820/udp"
      - "51821:51821/tcp"
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    sysctls:
      - net.ipv4.ip_forward=1
      - net.ipv4.conf.all.src_valid_mark=1
    networks:
      proxy:
        ipv4_address: 172.28.0.12

networks:
  proxy:
    external: true

Notas importantes:

  • PASSWORD_HASH é bcrypt. Cada $ precisa ser escapado como $$ dentro do compose (ou o Compose tentará interpolar variáveis).
  • Gerar hash: docker run --rm ghcr.io/wg-easy/wg-easy wgpw 'minha-senha' (versão >=14) ou usar htpasswd -bnBC 12 "" 'senha' | tr -d ':\n'.
  • WG_DEFAULT_ADDRESS=10.8.0.x — o x é literal; o wg-easy substitui pelo último octeto quando cria cada peer.
  • WG_ALLOWED_IPS controla o que vai no .conf gerado para os clientes — não confundir com o AllowedIPs do peer no servidor (que continua 10.8.0.<n>/32).
  • A imagem :14 ainda usa esse layout legado. A :15 mudou a UI e o esquema (auth interna, PASSWORD_HASH foi renomeado). Se for migrar, ler o changelog antes — a 15 introduz database SQLite em /etc/wireguard/db.sqlite.

6.2 Subir

docker compose up -d
docker compose logs -f wg-easy

Acessar a UI em http://<host>:51821 (ou via reverse proxy nginx atrás do allowlist 172.28.0.1).

6.3 Vanilla vs wg-easy — comparação

AspectoVanillawg-easy
Adicionar peerEditar wg0.conf + wg syncconfBotão “New Client” na UI
Distribuir .confscp / pendriveQR code direto da UI
Topologias complexas (site-to-site, múltiplas subnets)Total controle, Table, rotas customLimitado — UI assume hub-and-spoke
Métricaswg show, scripts customUI mostra rx/tx por peer
BackupCopiar /etc/wireguard/Volume ./data/ (inclui DB)
FootprintPacote do sistema (~1 MB)Container Node.js (~150 MB)
Multi-tenant / autenticação humanaNão temUI com senha (e 2FA na v15)
Roteamento custom (Table = off, PreUp/PostUp arbitrário)SimLimitado a WG_PRE_UP / WG_POST_UP env

Resumo: wg-easy ganha em ergonomia para o caso 90% (hub-and-spoke, alguns peers humanos). Vanilla ganha quando precisa de topologia em malha, múltiplas interfaces wgN, ou integração com frr/BGP.


7. Criando peers via UI wg-easy

  1. Abrir http://<host>:51821 (ou domínio atrás de nginx).
  2. Login com a senha definida em PASSWORD_HASH.
  3. Botão ”+ New Client” no canto superior direito.
  4. Informar nome do peer (ex.: laptop-user). Opcional: definir IP fixo, expiração, AllowedIPs custom.
  5. A UI mostra dois botões:
    • QR code: escanear direto no app do celular.
    • Download: baixa um .conf pronto para wg-quick.
  6. Para revogar: ícone de lixeira ao lado do peer. O servidor recarrega a config automaticamente.

Toggle de pausa (ícone de pause) suspende o peer sem deletar — útil para testes.


8. Importando no cliente

Linux

sudo cp ~/Downloads/laptop-user.conf /etc/wireguard/wg0.conf
sudo chmod 600 /etc/wireguard/wg0.conf
sudo wg-quick up wg0
sudo systemctl enable --now wg-quick@wg0   # persistir

Para derrubar: sudo wg-quick down wg0.

Windows / macOS

App oficial WireGuard (Microsoft Store / App Store / wireguard.com/install).

  • Importar túnel → escolher o .conf.
  • Ativar/desativar com toggle.
  • macOS pede permissão de System Extension na primeira vez.

Android / iOS

App WireGuard (Play Store / App Store, oficial do projeto).

  • +Create from QR code → apontar câmera para o QR da UI wg-easy.
  • Toggle de ativação.
  • iOS exige permissão “Adicionar configurações de VPN”.

9. Split tunnel vs full tunnel

O lado cliente controla isso via AllowedIPs:

# Split tunnel — só tráfego para a infraestrutura interna passa pela VPN
AllowedIPs = 10.8.0.0/24, 172.28.0.0/16

# Full tunnel — TODO tráfego do dispositivo sai pela VPS
AllowedIPs = 0.0.0.0/0, ::/0

Quando usar full tunnel:

  • Wi-Fi público (café, aeroporto). Toda navegação sai pelo IP da VPS.
  • Bypass de geoblocking baseado em IP de origem.
  • Esconder DNS da operadora.

Quando usar split:

  • Acesso pontual a serviços internos sem sacrificar latência de Netflix/Spotify.
  • Economia de banda da VPS (full tunnel faz dobrar o tráfego).

Observação: no wg-easy, WG_ALLOWED_IPS define o default do que vai no .conf gerado. Para diferenciar por peer, a UI da v14+ permite override no momento da criação.


10. DNS dentro do túnel

Sem DNS no [Interface] do cliente, a resolução continua usando o DNS do sistema — leak de DNS para a operadora local. Soluções:

# Cliente — usar DNS público
DNS = 1.1.1.1, 1.0.0.1

Ou apontar para um resolver interno da infraestrutura (AdGuard, Pi-hole, Unbound):

DNS = 172.28.0.x

No wg-easy, WG_DEFAULT_DNS=1.1.1.1 (ou múltiplos separados por vírgula) injeta isso em todo peer novo. Trocar o DNS de peers já criados exige editar via UI ou regenerar.

Verificar leak: rodar https://dnsleaktest.com com o túnel ativo.


11. Firewall e iptables

Servidor vanilla — INPUT mínimo

# Aceitar handshake WireGuard
sudo iptables -A INPUT -p udp --dport 51820 -j ACCEPT

# Manter conexões já estabelecidas
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# loopback
sudo iptables -A INPUT -i lo -j ACCEPT

# SSH só por dentro do túnel (depois que validar acesso)
sudo iptables -A INPUT -p tcp --dport 22 -s 10.0.0.0/24 -j ACCEPT

# Default DROP
sudo iptables -P INPUT DROP
sudo iptables -P FORWARD ACCEPT      # ou regras mais finas

Persistir com iptables-persistent:

sudo apt install -y iptables-persistent
sudo netfilter-persistent save

MASQUERADE

Já está nas PostUp/PostDown da seção 5.4. Sem MASQUERADE, os pacotes chegam aos serviços com source 10.0.0.x, mas a resposta tenta voltar pela rota default — sem rota reversa, drop.

wg-easy

O container faz tudo isso automaticamente quando recebe cap_add: NET_ADMIN. Inspeção:

docker exec wg-easy iptables -t nat -L POSTROUTING -n -v
docker exec wg-easy iptables -L FORWARD -n -v

12. Hardening

  1. SSH só dentro do túnel. Após validar acesso pela VPN:
# /etc/ssh/sshd_config
ListenAddress 10.0.0.1
PermitRootLogin no
PasswordAuthentication no
sudo systemctl reload sshd
  1. Fail2ban para a porta 51820 — pouco efetivo porque WireGuard não responde a pacotes inválidos. Útil mesmo é para SSH e nginx.

  2. Rotação de chaves: gerar novas a cada 6-12 meses ou imediatamente se um cliente foi roubado. Procedimento:

    • Revogar peer antigo (deletar [Peer] do servidor ou clicar lixeira no wg-easy).
    • Gerar novo par no cliente.
    • Adicionar novo peer com nova PublicKey.
  3. PresharedKey opcional, mas recomendado para defesa quântica futura:

wg genpsk > preshared
[Peer]
PublicKey = ...
PresharedKey = <PSK>
AllowedIPs = 10.0.0.2/32

Adiciona uma camada de XOR pós-quântica ao handshake.

  1. Não expor 51821/tcp publicamente. A UI do wg-easy fica atrás do nginx com allowlist 172.28.0.1 — só acessível depois que a VPN já está montada.

13. Múltiplos sites (site-to-site)

Cenário: VPS (servidor A, 10.0.0.1/24) interligada a um servidor doméstico (B, 10.0.1.1/24), com redes LAN distintas atrás.

Servidor A — /etc/wireguard/wg0.conf:

[Interface]
Address = 10.0.0.1/24
ListenPort = 51820
PrivateKey = <A_PRIV>

[Peer]
# Servidor B
PublicKey = <B_PUB>
AllowedIPs = 10.0.1.0/24, 192.168.1.0/24
Endpoint = b.dyndns.example.com:51820
PersistentKeepalive = 25

Servidor B (atrás de NAT residencial):

[Interface]
Address = 10.0.1.1/24
ListenPort = 51820
PrivateKey = <B_PRIV>

[Peer]
# VPS
PublicKey = <A_PUB>
AllowedIPs = 10.0.0.0/24, 172.28.0.0/16
Endpoint = 203.0.113.10:51820
PersistentKeepalive = 25

Ambos os lados precisam de ip_forward=1 e MASQUERADE/route apropriados para que as LANs internas (192.168.1.0/24 e 172.28.0.0/16) se enxerguem. No lado residencial, ainda é necessário aceitar o tráfego na firewall do roteador ou usar PersistentKeepalive para furar o NAT a partir de dentro.


14. Troubleshooting

Handshake nunca acontece

sudo wg show
# Procurar "latest handshake: ..." — se "0 seconds ago" nunca aparece, o handshake nunca completou

Checklist:

  1. UDP 51820 realmente aberto? sudo ss -ulnp | grep 51820 no servidor.
  2. Firewall do provedor (Oracle Cloud, AWS Security Group, Hetzner) bloqueando?
  3. Endpoint do cliente bate com IP/porta reais do servidor?
  4. PublicKey do servidor no cliente igual à de wg show interface no servidor?
  5. Hora dos dois lados sincronizada? WireGuard rejeita handshake com timestamp >2min de skew.
  6. NAT residencial assassinando o fluxo? Adicionar PersistentKeepalive = 25 no lado interno.

Capturar pacotes

sudo tcpdump -i any -n udp port 51820
sudo tcpdump -i wg0 -n

No primeiro deve aparecer fluxo UDP. No segundo, tráfego já decifrado.

MTU

Sintoma: ping funciona, mas downloads/SSH travam. Especialmente com fibra PPPoE.

[Interface]
MTU = 1380

Ou medir com ping -M do -s 1372 <peer> e ajustar.

Rotas

ip route show table all | grep wg0

wg-quick up adiciona uma rota por AllowedIPs. Se conflitar com rota existente, sai erro RTNETLINK answers: File exists — usar Table = off em [Interface] para gerenciar manualmente.

Logs

# Vanilla
sudo journalctl -u wg-quick@wg0 -f
sudo dmesg | grep -i wireguard

# wg-easy
docker compose logs -f wg-easy

Container wg-easy não consegue criar interface

Verificar cap_add: NET_ADMIN e que /lib/modules está montado (somente leitura é o suficiente). Em kernels que não carregam wireguard automaticamente:

sudo modprobe wireguard

Em hosts onde o módulo não existe (containers LXC sem kernel próprio), wg-easy cai para implementação userspace wireguard-go — funciona mas com latência maior.

Conflito de subnet

WG_DEFAULT_ADDRESS=10.8.0.x colide com 10.8.0.0/24 da LAN do cliente? O cliente vai mandar tráfego pelo túnel quando deveria ir local. Reservar uma subnet dedicada para o WG (10.66.66.0/24 é comum).


15. Backup

Itens críticos:

  • Chaves privadas (/etc/wireguard/privatekey, todos os .conf com PrivateKey inline). Sem elas, é necessário regenerar e redistribuir para cada peer.
  • Lista de PublicKeys dos peers (recuperável dos próprios clientes se eles tiverem backup, mas chato).
  • DB do wg-easy: volume ./data/ mapeado em /etc/wireguard dentro do container. Para v14 contém wg0.conf + arquivos de peer. Para v15 contém db.sqlite.

Script simples de backup diário:

#!/bin/bash
set -e
DEST=/var/backups/wireguard
mkdir -p "$DEST"
tar czf "$DEST/wg-$(date +%F).tar.gz" /etc/wireguard /opt/docker/wg-easy/data
find "$DEST" -name 'wg-*.tar.gz' -mtime +30 -delete

Cifrar antes de mandar para storage externo (chave privada em backup não cifrado é receita para desastre):

gpg --symmetric --cipher-algo AES256 "$DEST/wg-$(date +%F).tar.gz"

16. Referências