The Itch
I have a homelab full of services that only exist on my LAN. Frigate watches my cameras. Paperless holds every document I own. SiYuan has all my notes. Vaultwarden keeps my passwords. These all live behind my router on a private 192.168.1.x subnet, and that's where they should stay.
But sometimes I'm not home. I'm traveling, visiting family, or just out for the day. And I want to pull up my camera feeds, or grab a document from Paperless, or check on a service. I needed a VPN that I control, with no third party dependencies, that works on any device with a single config file.
WireGuard is the answer. It's fast, it's simple, and the entire config is about 10 lines. Every platform has a lightweight client. No accounts, no subscriptions, no background daemons phoning home.
And honestly, the real reason? I wanted to automate the entire thing. Terraform to provision the VM, Ansible to configure WireGuard, one command to go from nothing to a working VPN server. Build it locally, prove it works, then point the same automation at a cloud VPS later.
The Plan
Set up Dynamic DNS so my home IP stays reachable(done)Provision a VM on Proxmox via Terraform(done)Configure WireGuard server via Ansible(done)Port forward UDP 51820 through pfSense(done, eventually)Connect from phone on mobile data and see home IP(done)- Point the same Ansible playbook at a Hetzner VPS for production (future)
Dynamic DNS: Because ISP IPs Aren't Forever
My ISP Cable IPs are fairly stable (mine hasn't changed in months), but I didn't want to hardcode an IP into a VPN config that might break at 3am some random Tuesday.
The solution: a DNS record that pfSense keeps updated automatically.
Step 1: Create an A record in Cloudflare pointing to my current public IP. Set it to "DNS only" (not proxied, because WireGuard needs to see the real IP). TTL of 1 minute so changes propagate fast.
Step 2: Create a scoped Cloudflare API token with two permissions:
- Zone > DNS > Edit (to update the record)
- Zone > Zone > Read (so pfSense can look up the Zone ID)
Scope it to only your domain. Least privilege.
Step 3: In pfSense, go to Services > Dynamic DNS > Dynamic DNS Clients:
- Service Type: Cloudflare
- Interface: WAN
- Hostname:
vpn - Domain:
example.com - Username: leave blank (tells pfSense to use Bearer token auth)
- Password: paste the API token
- Cloudflare Proxy: unchecked
Save, force update, and you should see a green checkmark with your current IP. Now pfSense will update that DNS record whenever your ISP gives you a new address.
Provisioning the VM with Terraform
I use the bpg/proxmox Terraform provider to manage all my Proxmox VMs. The WireGuard server is tiny: 1 CPU core, 512MB RAM. It's basically just shuffling packets.
The critical thing I learned the hard way: you must include a clone block that references your cloud-init template. Without it, Terraform creates an empty VM with no OS and you stare at an iPXE boot prompt wondering what you did wrong. Also, the disk size must be >= your template's disk size (you can't shrink a clone).
resource "proxmox_virtual_environment_vm" "wireguard" {
name = "wireguard"
node_name = var.proxmox_node
vm_id = 130
# This is the line I forgot the first time
clone {
vm_id = var.template_vm_id # Ubuntu cloud-init template
}
agent { enabled = true }
cpu { cores = 1; type = "x86-64-v2-AES" }
memory { dedicated = 512 }
disk {
interface = "scsi0"
size = 23 # Must match or exceed template disk
datastore_id = var.datastore_id
}
initialization {
ip_config {
ipv4 {
address = "192.168.1.50/24"
gateway = var.gateway
}
}
dns { servers = var.dns_servers }
user_account {
keys = [trimspace(file(var.ssh_public_key_path))]
username = "ubuntu"
}
}
network_device { bridge = var.bridge }
scsi_hardware = "virtio-scsi-single"
on_boot = true
}
terraform apply clones the template (a few minutes for the full disk copy), configures cloud-init with the static IP and SSH key, and boots the VM. Wait about 30 seconds for cloud-init to finish, then you can SSH in.
Configuring WireGuard with Ansible
The Ansible playbook handles everything: install WireGuard, generate server and client keys, template the configs, enable IP forwarding, set up NAT masquerading, and output a client config you can import directly.
---
- name: Configure WireGuard VPN server
hosts: wireguard
become: true
vars:
wg_port: 51820
wg_server_addr: 10.66.66.1/24
wg_client_addr: 10.66.66.2/32
wg_interface: eth0
wg_dns: 192.168.1.1
tasks:
- name: Install WireGuard and qrencode
apt:
name: [wireguard, qrencode]
state: present
update_cache: true
- name: Generate server private key
command: wg genkey
register: wg_server_genkey
args:
creates: /etc/wireguard/server_private.key
- name: Save server private key
copy:
content: "{{ wg_server_genkey.stdout }}"
dest: /etc/wireguard/server_private.key
mode: "0600"
when: wg_server_genkey.changed
# ... derive public keys, generate client keys ...
- name: Enable IP forwarding
sysctl:
name: net.ipv4.ip_forward
value: "1"
state: present
reload: true
- name: Enable and start WireGuard
systemd:
name: wg-quick@wg0
enabled: true
state: started
- name: Generate QR code for phone import
shell: qrencode -t ansiutf8 < /etc/wireguard/client.conf
register: client_qr
changed_when: false
The server config uses PostUp and PostDown hooks to add and remove the iptables MASQUERADE rule. This is what makes the VM act as an exit node: client traffic enters the WireGuard tunnel, gets NAT'd to the VM's real IP, and exits through the home router to the internet.
[Interface]
PrivateKey = <server_private_key>
Address = 10.66.66.1/24
ListenPort = 51820
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
[Peer]
PublicKey = <client_public_key>
AllowedIPs = 10.66.66.2/32
The client config routes all traffic through the tunnel (AllowedIPs = 0.0.0.0/0), so when I check my public IP from my phone, I see my home IP. DNS goes through the tunnel too, pointed at my home router.
[Interface]
PrivateKey = <client_private_key>
Address = 10.66.66.2/32
DNS = 192.168.1.1
[Peer]
PublicKey = <server_public_key>
Endpoint = vpn.example.com:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
The playbook also installs qrencode so it spits out a QR code at the end. Open the WireGuard app on your phone, scan, done.
Run it all with:
ansible-playbook -i inventory.yml playbook-wireguard.yml --extra-vars '@wireguard-vars.yml'
Adding More Clients
The initial playbook creates one client. But you probably want your phone and your laptop connected at the same time. Each device needs its own keypair and tunnel IP, otherwise WireGuard gets confused about which peer is which.
I wrote a second playbook for this. Give it a name, it does the rest:
ansible-playbook -i inventory.yml playbook-wireguard-add-client.yml \
--extra-vars '@wireguard-vars.yml' \
--extra-vars 'client_name=work-laptop'
It generates a new keypair, auto-assigns the next available IP in the tunnel subnet (.3, .4, etc.), adds the peer to the running server without restarting, and spits out both a config file and a QR code. The config gets saved locally as wireguard-work-laptop.conf.
On a desktop, you import the .conf file into the WireGuard app. On a phone, you scan the QR code. That's it. No need to touch the server config manually or restart anything.
The pfSense Port Forwarding Saga
This is where I lost a few hours, over few days unfortunately.
The setup should be simple: forward UDP 51820 from WAN to the WireGuard VM. In pfSense, that's Firewall > NAT > Port Forward. Create the rule, done, right?
Not quite.
Problem 1: The auto-generated firewall rule was disabled. When pfSense creates a NAT port forward, it auto-generates an associated WAN firewall rule to allow the traffic. Mine was created in a disabled state. Packets were arriving at pfSense (I could see them in the firewall logs) but getting dropped by the "Default deny" rule because the pass rule wasn't active. Easy to miss.
Problem 2: The firewall rule destination address. This is the one that really got me. In pfSense, NAT translation happens before firewall rules evaluate. So when an external packet arrives destined for your WAN IP on port 51820, pfSense first translates the destination to the internal IP (192.168.1.50), and then checks the firewall rules. The firewall rule needs to match the translated destination (192.168.1.50), not the original WAN address. I had it set to "WAN address" and couldn't figure out why packets were being blocked even though the rule looked correct.
The debugging approach that actually worked: SSH into pfSense and check the loaded pf rules:
# Check if NAT redirect rule is loaded
pfctl -s all 2>&1 | grep 51820
# You should see both:
# rdr on igc0 ... -> 192.168.1.50 (NAT redirect)
# pass in quick on igc0 ... (firewall pass)
If you only see the pass rule but not the rdr rule, your NAT port forward is disabled or misconfigured.
On the WireGuard server, tcpdump tells you if packets are actually arriving:
sudo tcpdump -i eth0 udp port 51820 -n
Zero packets? The problem is upstream (pfSense). Packets arriving but no WireGuard handshake? The problem is the WireGuard config.
The Moment It Worked
I was sitting at my desk, connected to my home network via remote desktop from somewhere else entirely. Toggled the WireGuard VPN on my phone (mobile data, not WiFi), opened Safari, went to ifconfig.me, and there it was: my home IP.
Then I typed frigate.localdomain into the browser and my camera feeds loaded. From my phone. On a cell tower. Through an encrypted WireGuard tunnel, across the internet, into my home network, to a VM that routes the traffic onto my LAN as if I were sitting on the couch.
Paperless works. SiYuan works. Every .localdomain service just resolves and loads, because the VPN tunnel uses my home router as its DNS server.
What I'd Do Differently
Don't forget the clone block in Terraform. I stared at an iPXE boot prompt for longer than I'd like to admit.
Read the pfSense NAT processing order docs first. Understanding that NAT happens before firewall rule evaluation would have saved me an hour of debugging. The auto-generated associated filter rule had the right destination all along. I "fixed" it by changing it to the wrong one.
Test from actual external networks. NAT port forwards don't work from inside the LAN (hairpin NAT). I wasted time testing from a machine on the same network before switching to my phone on cellular.
What's Next
Right now this runs on a local Proxmox VM, which means my VPN server and my home internet share the same upload pipe. That's fine for accessing services, but if I wanted to use it as a general purpose exit node (tunnel all my browsing through it), my home upload speed becomes the bottleneck.
The fix: run the same Ansible playbook against a cheap Hetzner VPS in Ashburn. $4/month, 1Gbps symmetric, US IP. The automation is already proven locally. One ansible-playbook command pointed at a different inventory and it's live. That's the whole point of building it this way.
Featured Image Prompt
I used this prompt to generate the featured image.
A stylized isometric illustration of a glowing WireGuard tunnel connecting a smartphone in a coffee shop to a home server rack, with encrypted data packets flowing through. The tunnel passes through a city skyline, through clouds, and into a cozy house where camera feeds and documents are visible on tiny screens. Neon blue and purple color palette, dark background, clean geometric style, homelab aesthetic with a sense of security and privacy. No text.