🎧 Listen to the 60-Second Audio Recap:
The Core Problem This Solves
A self-hosted WireGuard VPN puts you back in control. Commercial VPNs do not make you private. They ask you to displace your trust: instead of your ISP, you now trust a company whose “no-log policy” you have no way to verify. When you self-host your VPN, you own the server, you own the logs, and you own the keys.
Three concrete reasons to run your own WireGuard server at home:
- Encrypted tunnel on public Wi-Fi. Coffee shop, hotel, airport — all your traffic is encrypted back to your own hardware before it hits the internet.
- Remote access to home network resources. Reach your NAS, Home Assistant dashboards, Jellyfin, Nextcloud, or any other self-hosted service as if you were sitting on your couch.
- Ad-blocking on the road via Pi-hole DNS routing. Point your VPN’s DNS at your Pi-hole and every device connected to the VPN gets ad-blocking everywhere, not just at home.
Why a Self-Hosted WireGuard VPN with wg-easy?
WireGuard is a modern VPN protocol built directly into the Linux kernel. It uses state-of-the-art cryptography (ChaCha20, Curve25519, BLAKE2s), has a codebase of roughly 4,000 lines (compared to OpenVPN’s hundreds of thousands), and is measurably faster than any alternative. It is not experimental — it has been part of the mainline Linux kernel since version 5.6.
wg-easy wraps WireGuard in a clean web UI that handles client creation, QR code generation, and connection monitoring without you ever touching the WireGuard CLI for day-to-day management.
Community signal: The most upvoted answer on r/WireGuard for home setup questions consistently points to wg-easy + Docker as the lowest-friction path. This guide covers what those threads leave out: the bcrypt password requirement in v14+, the Proxmox LXC kernel module problem, and how to wire Pi-hole into the DNS chain.
Prerequisites and Minimal Hardware
What You Need Before You Start
- Docker and Docker Compose with Portainer installed. See our prerequisite guide: Build Your Privacy Machine Room: Docker + Portainer on Proxmox LXC.
- One UDP port you can forward in your router. Default is UDP
51820. - A public IP address or a DDNS hostname (e.g.,
vpn.yourdomain.com) if your ISP assigns dynamic IPs. - A Pi-hole instance with a known static LAN IP. Pro Paul route only. Skip this if you are following the Newbie Nora path.
The Port-Forward Exception — Read This First
Warning: This is the one guide in the SelfHostHero series where opening an inbound port is not optional. A VPN endpoint requires an inbound UDP port by design. You will open UDP 51820 only. The web UI on port
51821stays internal and is never exposed to the internet.
Proxmox Users — The Kernel Module Problem
Heads up: WireGuard requires access to the
wireguardkernel module and the/dev/net/tundevice. Unprivileged LXC containers block both by default. Run wg-easy in a small VM or a privileged LXC. See “The Ugly Truth” section below for the full breakdown and a decision table.
Minimal Hardware Footprint
wg-easy is lightweight. The table below shows what a dedicated deployment needs:
| Resource | Minimum | Notes |
|---|---|---|
| RAM | 256 MB | Comfortably handles 10+ clients |
| CPU | 1 vCPU | WireGuard is kernel-level; overhead is negligible |
| Disk | ~500 MB | For the container image and the config volume |
Method 1 — The Quick Start (Newbie Nora Route)
What Nora Will Achieve
A working VPN tunnel on her phone in under 15 minutes, using a plain-text password for testing, with no Pi-hole integration required.
Step 1 — Pull and Run the wg-easy Container
Run the following command on your Docker host. This is a quick-start command for testing only. The plain PASSWORD variable is deprecated in wg-easy v14+ and must not be used in production. The secure method is covered in the Pro Paul route.
docker run -d \
--name wg-easy \
-e WG_HOST=YOUR_PUBLIC_IP_OR_DDNS \
-e PASSWORD=YourTestPassword \
-p 51820:51820/udp \
-p 51821:51821/tcp \
-v ~/.wg-easy:/etc/wireguard \
--cap-add NET_ADMIN \
--cap-add SYS_MODULE \
--sysctl net.ipv4.ip_forward=1 \
--sysctl net.ipv4.conf.all.src_valid_mark=1 \
--restart unless-stopped \
ghcr.io/wg-easy/wg-easy:latest
Replace YOUR_PUBLIC_IP_OR_DDNS with your actual public IP address or your DDNS hostname (e.g., vpn.yourdomain.com). This value is embedded in the client configuration files wg-easy generates. If it is wrong, clients will not connect.
Step 2 — Forward UDP 51820 in Your Router
Log in to your router’s admin panel and create a port-forward rule with these settings:
- Protocol: UDP
- External port: 51820
- Internal destination IP: The static LAN IP of your Docker host (e.g.,
192.168.1.50) - Internal port: 51820
Every router brand has a slightly different UI, but the fields above are universal. Your Docker host must have a static LAN IP — if it changes, the port forward breaks.
Step 3 — Open the Web Interface
From any device on your home network, navigate to:
http://<server-ip>:51821
You will see the wg-easy login screen. Enter the password you set in the PASSWORD environment variable. This URL is LAN-only. Do not expose port 51821 to the internet.
Step 4 — Create Your First Client and Scan the QR Code
- Click New Client in the wg-easy interface.
- Give it a descriptive name (e.g.,
iphone-nora). - A QR code will appear on screen.
- Install the official WireGuard app on your phone: available on the iOS App Store and Google Play Store.
- In the WireGuard app, tap the + button and choose Scan from QR Code.
- Scan the code and tap Activate.
Step 5 — Test on Mobile Data (Not Your Home Wi-Fi)
Critical: Testing on your own Wi-Fi proves nothing. Your phone is already on your home network, so the VPN tunnel is irrelevant. Switch to mobile data, enable the VPN, then visit a “what is my IP” site such as whatismyip.com. You must see your home public IP address, not your mobile carrier’s IP. That confirms the tunnel is working end-to-end.
Method 2 — The Pro Setup (Pro Paul Route)
What Paul Will Achieve
- A bcrypt-hashed password stored securely in the Compose file
- Pi-hole as the DNS server for all VPN clients, delivering ad-blocking on the road
- A persistent config volume so client profiles survive container restarts and updates
- A restart-safe deployment managed via Docker Compose in Portainer
Step 1 — Generate the bcrypt Password Hash
wg-easy v14 and later require the PASSWORD_HASH environment variable containing a bcrypt hash. The old plain-text PASSWORD variable is deprecated and will not work on current versions. Generate your hash with the following command:
docker run --rm ghcr.io/wg-easy/wg-easy wgpw 'YourStrongPassword'
The output will look something like this:
PASSWORD_HASH=$2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
Copy the full hash string starting from $2a$. You will paste it into the Compose file in the next step.
Critical warning — dollar sign escaping: Every
$character in the bcrypt hash must be doubled to$$insidedocker-compose.yml. Docker Compose interprets a single$as the start of a variable substitution. A single unescaped$will silently corrupt the hash and lock you out of the web interface. For example,$2a$12$becomes$$2a$$12$$in the Compose file.
Step 2 — Deploy the Full Stack via Docker Compose
Create a new file named docker-compose.yml with the following content. Read the inline comments carefully — each non-obvious line is explained.
services:
wg-easy:
image: ghcr.io/wg-easy/wg-easy:latest
container_name: wg-easy
restart: unless-stopped
environment:
# Your public IP address or DDNS hostname.
# This is embedded in every client config file wg-easy generates.
# If this is wrong, clients will never connect.
- WG_HOST=vpn.yourdomain.com
# bcrypt hash of your password. Every $ must be doubled to $$ here.
# Example: $2a$12$abc... becomes $$2a$$12$$abc...
- PASSWORD_HASH=$$2a$$12$$...paste-your-escaped-hash-here...
# The UDP port WireGuard listens on. Match this to your port-forward rule.
- WG_PORT=51820
# DNS server pushed to all VPN clients.
# Set this to your Pi-hole's static LAN IP for ad-blocking on the road.
# Remove this line if you do not have Pi-hole.
- WG_DEFAULT_DNS=10.10.10.x
volumes:
# Persistent storage for WireGuard config and client keys.
# Change this path to match your storage layout.
- /mnt/snelle_data/App_Data/wg-easy:/etc/wireguard
ports:
# WireGuard VPN tunnel — must be forwarded in your router (UDP only).
- "51820:51820/udp"
# wg-easy web UI — keep this LAN-only, never expose to the internet.
- "51821:51821/tcp"
cap_add:
# NET_ADMIN: required to create and manage the WireGuard network interface.
- NET_ADMIN
# SYS_MODULE: required to load the wireguard kernel module.
- SYS_MODULE
sysctls:
# Enable IP forwarding so the container can route traffic between clients and the internet.
- net.ipv4.ip_forward=1
# Required for WireGuard's source address validation to work correctly.
- net.ipv4.conf.all.src_valid_mark=1
Step 3 — Deploy in Portainer
- Open Portainer in your browser and navigate to your environment.
- In the left sidebar, click Stacks.
- Click Add Stack.
- Give the stack a name (e.g.,
wg-easy). - Select Web editor and paste your complete
docker-compose.ymlcontent into the editor. - Click Deploy the stack.
- Portainer will pull the image and start the container. Watch the log output for any errors.
Step 4 — Forward UDP 51820 in Your Router
This step is identical to Nora’s Step 2 above. Forward UDP port 51820 to the static LAN IP of your Docker host. Only this one UDP port needs to be open. Port 51821 (the web UI) stays internal.
Step 5 — Create Clients and Verify Pi-hole DNS Routing
- Navigate to
http://<server-ip>:51821and log in with your password. - Click New Client, name it using the
device-ownerconvention (e.g.,iphone-paul), and scan the QR code with the WireGuard app. - Activate the VPN on your phone using mobile data.
- Open your Pi-hole dashboard and navigate to Query Log.
- You should see DNS queries arriving from your phone’s VPN tunnel IP (typically in the
10.8.0.xrange). This confirms Pi-hole is handling DNS for your VPN traffic.
Configuration and Validation — How to Confirm Everything Works
Validation Check 1 — The Handshake Test
In the wg-easy web interface, each connected client shows a latest handshake timestamp. When the VPN is active and working, this timestamp will read a few seconds or minutes ago and will update regularly. A handshake is the cryptographic exchange that proves both sides have valid keys and can communicate.
If the handshake timestamp never appears or shows “never,” the connection was never established. Go directly to the Troubleshooting section.
Validation Check 2 — The IP Address Test
On your phone, with mobile data active and the VPN enabled:
- Visit whatismyip.com or any similar service.
- Expected result: You see your home public IP address.
- Failure result: You see your mobile carrier’s IP. The VPN tunnel is not routing your traffic. Check the handshake status and the sysctls in your Compose file.
Validation Check 3 — The Pi-hole Ad-Block Test (Pro Paul Only)
- With the VPN active on mobile data, open your Pi-hole dashboard.
- Navigate to Query Log and confirm you see DNS queries from your phone’s VPN tunnel IP (e.g.,
10.8.0.2). - Optionally, visit a known ad-heavy website on your phone and confirm ads are blocked. This proves the full chain: VPN tunnel → Pi-hole DNS → ad-blocking on mobile data.
Post-Validation Hardening Checklist
- wg-easy running with a persistent
/etc/wireguardvolume - UDP 51820 forwarded in router; port 51821 is LAN-only
- Client connected with active handshake confirmed in wg-easy UI
- Home IP confirmed via external IP check on mobile data
- Pi-hole queries visible from VPN client tunnel IP (Pro Paul)
The Ugly Truth — Honest Quirks and Limitations
You Are Opening a Port. Own That Decision.
This is the only guide in the SelfHostHero series that requires an inbound port. That is not a design flaw — it is how VPN endpoints work. There is no workaround. A client outside your network needs a door to knock on.
The mitigation is straightforward: keep wg-easy updated. WireGuard’s own attack surface is minimal by design (the small codebase is a deliberate security feature). The wg-easy web UI is an additional layer, so keep it patched and never expose port 51821 externally.
Proxmox Unprivileged LXC — It’s a Known Headache
WireGuard needs two things that unprivileged LXC containers block by default: the wireguard kernel module and access to /dev/net/tun. You can work around this with manual Proxmox host configuration, but it is fragile and not worth the effort for most users. Here is the honest decision table:
| Environment | Difficulty | Recommended? |
|---|---|---|
| Small VM (e.g., 512 MB Debian) | Low | Yes |
| Privileged LXC | Medium | Acceptable |
| Unprivileged LXC | High | Avoid unless experienced |
If you are already running a Proxmox setup and want to understand your VM and LXC options better, our guide on which self-hosting platform is right for you covers the trade-offs in detail.
Privacy Scope — What This VPN Does and Does Not Do
Be clear about what you are building:
- Encrypts your traffic on public Wi-Fi back to your home hardware
- Gives you secure remote access to all your LAN resources
- Routes DNS through Pi-hole for ad-blocking on the road
- Does not hide your identity — your exit IP is your home IP, which is traceable to you
- Does not spoof your geographic location
- Does not protect you from your own ISP seeing your traffic at the home connection level
This VPN is built for security and remote access. It is not an anonymity tool.
Troubleshooting Common Errors
Error — Client Shows No Handshake (Connection Never Established)
Cause: Almost always a misconfigured port forward or an incorrect WG_HOST value.
Fix steps:
- Verify that UDP 51820 is forwarded to the correct static LAN IP of your Docker host in your router settings.
- Confirm that
WG_HOSTin your Compose file matches your actual current public IP or DDNS hostname exactly. - Test exclusively via mobile data. Testing from your home Wi-Fi is invalid and will give false results.
- Check the WireGuard interface status inside the container:
docker exec -it wg-easy wg show
This command shows the active WireGuard interface, listening port, and any peer handshake data. If the interface is not listed, the container itself has a configuration problem.
Error — Cannot Log In to the Web Interface
Cause: Using the deprecated plain-text PASSWORD variable on wg-easy v14+, or failing to escape $ characters in the bcrypt hash inside docker-compose.yml.
Fix steps:
- Regenerate your bcrypt hash using the
wgpwcommand shown in Pro Paul Step 1. - Open your
docker-compose.ymland verify that every single$in the hash value is doubled to$$. There are typically three or four$characters in a bcrypt hash. - Redeploy the stack in Portainer (pull and redeploy, or remove and recreate).
Error — Handshake Succeeds but No Internet or No LAN Access
Cause: The IP forwarding sysctls are missing or were not applied correctly. Without IP forwarding, the container accepts the VPN connection but cannot route packets anywhere.
Fix steps:
- Confirm that both
net.ipv4.ip_forward=1andnet.ipv4.conf.all.src_valid_mark=1are present in thesysctlsblock of your Compose file. - Restart the container:
docker restart wg-easy
- Verify the sysctl values are active inside the running container:
docker exec -it wg-easy sysctl net.ipv4.ip_forward
The expected output is:
net.ipv4.ip_forward = 1
If you see 0, the sysctl was not applied. This is a common symptom of running in an unprivileged LXC that blocks sysctl modifications.
Error — wg-easy Container Fails to Start on Proxmox LXC
Cause: An unprivileged LXC container is blocking kernel module access. The container log will typically show an error related to loading the wireguard module or accessing /dev/net/tun.
Fix: Migrate the container to a small VM or a privileged LXC. See “The Ugly Truth” section above for the full decision table. There is no reliable quick fix for this in an unprivileged LXC without significant Proxmox host-level configuration.
Conclusion and Next Steps
What You Have Now
- A fast, modern WireGuard VPN running in Docker on your own hardware
- A secure web UI protected by a bcrypt-hashed password (Pro Paul)
- One UDP port open; all other services remain unexposed
- Ad-blocking on mobile via Pi-hole DNS routing (Pro Paul)
- A validated, working tunnel confirmed by handshake check and external IP verification
Immediate Next Step — DDNS Client Setup
If your ISP does not give you a static public IP (most residential connections do not), your public IP will change periodically. When it does, WG_HOST will point to the wrong address and your VPN will stop working silently. The fix is a DDNS client container — a lightweight service that watches your public IP and updates your DNS record automatically. Options include oznu/cloudflare-ddns and linuxserver/ddclient, both deployable in a single Compose block. Without this, your VPN breaks every time your ISP rotates your IP.
Expand Your VPN — Additional Clients
Each person and device should have its own client profile. This gives you independent revocation: if a phone is lost, you delete that one client without affecting anyone else. Use the device-owner naming convention: iphone-paul, laptop-nora, tablet-kids. Each client gets its own QR code and its own cryptographic key pair.
Related Guides in This Series
- Build Your Privacy Machine Room: Docker + Portainer on Proxmox LXC — prerequisite for this guide
- Homepage Dashboard on Proxmox LXC: API Setup Guide — a clean front door for all your self-hosted services
- Which Self-Hosting Platform Is Right for You? Bare Metal, Docker, or Proxmox? — if you are still deciding where to run all of this
- Install Home Assistant OS on Proxmox VM — the home automation hub you will want to access securely through your new VPN