I wanted to reach my self hosted services from outside my home, on 5G or on a public network, without exposing every service directly to the internet. A VPN was the clean answer. Here is how I set up WireGuard inside a lightweight LXC container on Proxmox, automated client creation, and wired it to my internal DNS and reverse proxy so everything is reachable by domain name.
Why WireGuard
My Proxmox node only has 8 GB of RAM shared between a handful of VMs and containers, so resource usage mattered. WireGuard was the obvious pick: it lives in the Linux kernel, uses only a few MB of memory, and its configuration is a short, readable file instead of the dozens of parameters an OpenVPN setup usually needs. Modern cryptography, tiny attack surface, and excellent performance even on a modest CPU made the decision easy.
I chose to run it in a dedicated LXC container rather than directly on the host. That keeps the VPN isolated from the rest of the node, easy to snapshot, and easy to rebuild if I ever break something.
The architecture
My homelab uses two bridges: vmbr0 faces my home network, and vmbr1 is an internal network (192.168.1.0/24) where my containers live. The WireGuard container sits on vmbr1, alongside my Technitium DNS server and my Nginx Proxy Manager instance.
Once a client is connected, it gets an address on the 10.0.0.0/24 tunnel network and can reach the whole internal subnet as if it were sitting on vmbr1. That means I never add a service as a VPN peer. I only add a peer for each device I carry around, like my phone or my laptop.
Installing WireGuard in the LXC
Two details are specific to running WireGuard inside a container, and both are easy to miss.
A container does not get access to /dev/net/tun by default. Before starting the container, I added these two lines to its config on the Proxmox host, in /etc/pve/lxc/<CTID>.conf:
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file
An LXC container shares the host kernel, so the wireguard module has to be loaded on the Proxmox node, not inside the container. A quick modprobe wireguard on the host, plus an entry in /etc/modules to make it persistent, and the container can use it.
Inside the container, the rest is standard:
apt update && apt install -y wireguard wireguard-tools qrencode
cd /etc/wireguard
umask 077
wg genkey | tee server_private.key | wg pubkey > server_public.key
The server config in /etc/wireguard/wg0.conf defines the tunnel interface, the listening port, and the forwarding rules that let VPN clients reach the internal network. I also enabled IP forwarding with net.ipv4.ip_forward = 1 in sysctl.conf, then brought the interface up and enabled it at boot:
systemctl enable --now wg-quick@wg0
wg show
Automating client creation
Adding a peer by hand means generating keys, picking the next free IP, editing the server config, and building a client file. That is tedious and error prone, so I wrote a small bash script that does all of it in one command:
wg-add-client laptop
The script generates the key pair, finds the next available address on the tunnel subnet, appends the peer to the server config, reloads WireGuard without dropping existing connections using wg syncconf, and finally prints a QR code so I can set up the phone in seconds. Every client config is saved under /etc/wireguard/clients/ so I always have a copy.
An interface like WG-Easy would do the same job from a web page, but for adding a device now and then, a one line command is lighter, has no extra web service to expose, and taught me more about how WireGuard actually works.
Access by domain name: DNS and reverse proxy
Reaching services by IP and port works, but I wanted jellyfin.home.lan rather than 192.168.1.14:8096. Two pieces make that happen.
First, the WireGuard client config sets DNS = 192.168.1.11, the internal address of my Technitium server. When the tunnel is up, every DNS query travels through it and is answered by Technitium. In Technitium I created A records for each service, all pointing at the Nginx Proxy Manager address (192.168.1.12), because it is the reverse proxy that routes to the right backend based on the requested hostname.
Second, Nginx Proxy Manager holds one proxy host per service, mapping the domain name to the correct internal IP and port. So the flow becomes: the client asks Technitium for jellyfin.home.lan, gets the proxy IP, sends the request with the hostname in the Host header, and the proxy forwards it to Jellyfin. Nothing needs to change on the proxy whether I come from home or from the VPN, because it only cares about the hostname it receives.
Pitfalls worth knowing
A few things tripped me up along the way. None were hard once understood, but they are the kind of thing that can cost an evening if you do not know they exist.
You cannot always test from home
Connecting to your own public IP from inside your own network relies on hairpin NAT, which many consumer routers do not support. My tunnel showed traffic going out but nothing coming back, and I assumed something was broken. It was not. Test from a real external network, like your phone on mobile data, or temporarily point the client endpoint at the local Proxmox address to validate the rest of the setup.
A dynamic public IP breaks a hardcoded endpoint
My ISP hands out dynamic addresses, so my clients pointed at an IP that eventually changed, and the tunnel silently stopped working. The fix is a dynamic DNS service, like DuckDNS, updated by a small cron job on Proxmox. Clients then use a stable name such as myhomelab.duckdns.org:51820 and no longer care when the address rotates.
Android Private DNS bypasses the tunnel DNS
On my phone, services would not resolve by name even with the VPN up. The cause was Android's Private DNS (DNS over TLS), which sends queries straight to a public resolver and ignores the DNS pushed by WireGuard. Setting Private DNS to Off in the system settings let the tunnel's resolver take over, and my internal names started resolving.
wg-quick needs a resolver backend on Linux
On my Arch laptop the tunnel refused to come up, deleting the interface with a DNS related error. The reason is that wg-quick applies the DNS = line through systemd-resolved, which was not running. Enabling it with systemctl enable --now systemd-resolved fixed it. Installing openresolv is an alternative if you prefer not to use systemd-resolved.
Watch your wildcard DNS records
A leftover wildcard record in my DNS zone was answering for a subdomain that had no explicit entry, sending me to the wrong address. If a name resolves to something unexpected, check for a * record taking precedence, and create an explicit A record for the service so it wins. Flushing the client DNS cache after a change saves a lot of confusion while testing.
Hitting the proxy by IP is not the same as by name
Opening the proxy's IP directly shows its default landing page, not your service, because the reverse proxy has no hostname to route on. That is expected behaviour, not a bug. Routing only works when the request carries the domain name, which is exactly why the DNS layer matters.
Result
I now have a lightweight, always available VPN that lets me reach my whole homelab from anywhere by domain name, with my internal DNS and reverse proxy doing the routing exactly as they do at home. Adding a new device is a single command, and thanks to dynamic DNS I no longer worry about my public address changing.
The build touched a bit of everything: virtualization, containers, networking, DNS, reverse proxying, and a touch of scripting. That mix is exactly what I enjoy about running a homelab, and each small pitfall along the way taught me something I will not forget.