Skip to content
twinkling.topServicesWireGuard (wg-easy)中文
boardany Pi 3 and up, including a Zero 2 W
kernel6.6.51+rpt-rpi-v8
idle temp48 °C
archarm64
reference host: Pi 4B 8GB, Bookworm 64-bit

Deployment recipe · Network & access

WireGuard with wg-easy: getting to the rack from outside without opening anything

The point of a VPN on a home rack is not anonymity, it is not having to expose Jellyfin, Nextcloud and Immich to the internet. One UDP port and a kernel module replace four forwarded TCP ports and four login pages that the whole internet can hammer. wg-easy is the thinnest possible layer over WireGuard: a web UI that generates peer configs and shows handshake times, plus the tunnel itself.

ghcr.io/wg-easy/wg-easy:15.0.1 · 2026-09-03

WireGuard (wg-easy) rack plate: board stack and host ports
service parameters
imageghcr.io/wg-easy/wg-easy:15.0.1
host ports:51820/udp, :51821/tcp
volume path/srv/homelab/wg-easy/data
RAM40 MB
CPU share0.15 vCPU (cpus: "0.15") — the kernel does the work, not the container
update cadenceonce or twice a year, pinned tag. Breaking changes are rare but the config file format has changed between major versions
arm64arm64 native; the container ships the wireguard-tools userspace, the kernel module comes from the host
port map and exposure
hostcontainerprotoexposedused for
:5182051820udppublicthe tunnel itself; the only UDP port forwarded at the router
:5182151821tcpLAN onlyweb UI for creating client configs and reading handshake times

Deployment steps

  1. Generate the UI password hash on the host

    The container takes a bcrypt hash, not a plaintext password. Generating it with the image itself avoids a version mismatch between your local bcrypt and the one in the container.

    run
    docker run --rm ghcr.io/wg-easy/wg-easy:15.0.1 wgpw 'the-password' 
    # prints PASSWORD_HASH='$2a$12$...' — put that line in /srv/homelab/.env
  2. Forward exactly one port, and nothing else

    51820/udp to the Pi, and 51821 stays closed. If you are behind carrier NAT without a public address, this is the step that cannot be done and you need a relay instead.

    run
    # router: UDP 51820 -> 192.168.10.20
    # verify from outside: nc -uzv your.public.ip 51820
  3. Start the container and create the first client

    The first client config is generated in the UI. Import it by QR code on a phone; on a laptop use the downloadable .conf with the official client.

    run
    cd /srv/homelab/wg-easy && docker compose up -d
    docker logs --tail 20 wg-easy
    docker exec wg-easy wg show
  4. Prove split tunnel does what you think

    With the tunnel up on a phone, a request to the Pi's LAN address should work and a request to a public IP-checking site should show your mobile carrier's address, not your home one. If the second shows your home address, AllowedIPs is wider than you set it.

    run
    docker exec wg-easy wg show wg0 allowed-ips
    # expect 10.8.0.2/32 for a client, and the client side should route only the LAN
  5. Save the data directory somewhere else too

    Server keys and every client config live in one directory. Copy it after each change, and export an archive into the password manager at the same time — that archive is what saves you when the Pi is the thing that failed.

    run
    tar czf /srv/homelab/backup/wg-easy-$(date +%F).tgz -C /srv/homelab/wg-easy data

One forwarded port instead of four

Before this, the rack had four ports forwarded: 443 for the proxy, and three application ports for the services that the family wanted to reach from outside. Every one of them was a login page exposed to the internet, and the log files showed the scanning starting within hours.

Now exactly one UDP port is forwarded. Nothing can be discovered by port scanning, because an unauthenticated WireGuard packet produces no response at all — unlike an HTTPS login page, which answers with a 200 and a password form.

approachports openwhat a scanner sees
forward each app443 + 3 app portsfour login pages to try passwords against
reverse proxy everything80 + 443one proxy, still a public attack surface
WireGuard + LAN-only apps443 + 51820/udpa web server and silence on UDP

Split tunnel by default

WG_ALLOWED_IPS is set to the LAN and the VPN subnet, not 0.0.0.0/0. That means the phone routes only rack traffic through the tunnel and everything else goes straight out over mobile data, which is faster, saves battery, and stops the Pi from becoming the bottleneck for someone else's video call.

Two devices get full-tunnel profiles: the laptop that gets used on cafe wifi, and the tablet that travels. Those two are the ones where the point is the encrypted exit, not the access.

DNS inside the tunnel, and why it is the useful part

Pointing WG_DEFAULT_DNS at the Pi-hole address is what makes the tunnel worth having for more than file access: ad blocking follows the phone onto mobile data, and the split-horizon names (jellyfin.lan, cloud.lan) resolve from anywhere.

The failure mode to know about is a phone that keeps the tunnel up on a captive-portal network, which makes every request fail until the portal is passed. Set it to on-demand rather than always-on for phones that travel.

shell
docker exec wg-easy wg show
# latest handshake within the last two minutes means the peer is connected now

Revocation is the feature nobody tests

The reason to have one profile per device rather than one shared profile is revocation. A lost phone should cost one deletion, not a re-issue for the whole household.

Test it once: delete a profile, confirm wg show drops the peer, and confirm the phone gets nothing. It takes five minutes and it is the only way to know your revocation actually works.

compose file

Drop the whole file at /srv/homelab/wg-easy/compose.yaml. Tags are pinned, never latest: rolling back on a Pi is far more work than upgrading.

compose.yaml
services:
  wg-easy:
    image: ghcr.io/wg-easy/wg-easy:15.0.1
    container_name: wg-easy
    restart: unless-stopped
    ports:
      - "51820:51820/udp"
      - "51821:51821/tcp"
    environment:
      TZ: Asia/Shanghai
      WG_HOST: rack.twinkling.top
      PASSWORD_HASH: ${WG_UI_HASH}
      WG_PORT: "51820"
      WG_DEFAULT_ADDRESS: 10.8.0.x
      WG_DEFAULT_DNS: 192.168.10.20
      WG_ALLOWED_IPS: 192.168.10.0/24,10.8.0.0/24
      WG_PERSISTENT_KEEPALIVE: "25"
    volumes:
      - /srv/homelab/wg-easy/data:/etc/wireguard
    cap_add:
      - NET_ADMIN
      - SYS_MODULE
    sysctls:
      - net.ipv4.conf.all.src_valid_mark=1
      - net.ipv4.ip_forward=1
    networks: [rack]

networks:
  rack:
    external: true

Hardening checklist

  • the UI is on the LAN only and additionally behind basic auth at the proxy; it is never forwarded
  • one client per device, named, so a lost phone can be revoked by deleting exactly one profile
  • DNS inside the tunnel is set to the Pi-hole address, so blocking applies on the road too
  • AllowedIPs is narrowed to the LAN for devices that should only reach the rack, and full-tunnel only for the two that need privacy on public wifi

Backup plan

the data directory holds the server key pair and every client config, so it is copied after any change and kept in the password manager as an exported archive. Losing it means re-issuing every phone and laptop profile, which is twenty minutes of work you do not want while travelling.

Verify it went in clean

  • wg show lists a handshake within the last two minutes for the device you are testing from
  • A phone on mobile data reaches jellyfin.lan and does not see ads, which proves DNS is going through the tunnel
  • Deleting a peer stops that device immediately, verified from the device rather than from the UI
  • Port 51821 is unreachable from outside the house

What bit us

  • WG_HOST must be the public hostname or IP the client will connect to, not the LAN address. Getting it wrong produces a config that only works inside the house, which is the opposite of the point.
  • A phone with a captive-portal wifi and an always-on tunnel cannot load the portal page, because the portal is not in AllowedIPs. Use on-demand activation.
  • The kernel module is loaded from the host. On a Pi running a mainline kernel without the wireguard module built, the container starts and the tunnel never comes up — check lsmod | grep wireguard.
  • Copying a client config into a second device works and defeats revocation. One profile per device, always.

Hardware questions

WireGuard or Tailscale?
WireGuard if you control the router and want no third party in the path; Tailscale if you are behind carrier NAT, want zero configuration and are willing to depend on someone else's coordination server. This rack runs both: WireGuard for the household, Tailscale on one node for a remote family member.
Does the VPN slow down Jellyfin streaming from outside?
The Pi's WireGuard throughput is around 90 Mbps on a Pi 4, which is enough for a direct-play 1080p stream and not enough for a 4K remux. If remote streaming is the main use, the honest answer is to keep the bitrate under 10 Mbps rather than to expect the Pi to push more.
How much RAM does this cost?
About 40 MB. The container is a control plane; the encryption happens in the kernel, so the resource cost does not scale with traffic in the way people expect.