A small WireGuard VPN server in its own Proxmox container, reached as vpn.503mail.com, so my Android phone can get to everything on my home network from anywhere. It replaced a SoftEther server in a 4 GB Windows VM whose management window had no dark mode. This post is the complete rebuild guide, and every code block has a Copy button.
How it works
- The phone runs the free WireGuard app with two tunnels that share one key:
- home sends only home-network traffic (the LAN and the tunnel subnet) through the VPN. Normal internet goes direct, which saves battery.
- home-all sends all traffic through home, using the home router for DNS. Good on public Wi-Fi. Only one can be on at a time, so picking one in the app acts as the "all traffic" toggle.
- The phone sends encrypted UDP packets to
vpn.503mail.comport 51820. That name is covered by the wildcard DNS record for 503mail.com, which points at the home public IP. - The home router forwards UDP 51820 to the WireGuard container.
- The container decrypts the packets and NATs them out of its LAN interface (iptables MASQUERADE), so the rest of the network sees them coming from the container and no LAN routes are needed.
- Connecting takes under a second. The phone sends a keepalive every 25 seconds, so the tunnel survives switching between Wi-Fi and mobile data.
Containers involved
| Container | Role | LAN IP |
|---|---|---|
| CT 302 (wireguard) | WireGuard server, Debian 12, unprivileged | x.x.0.236 (static) |
Sizing: 1 core, 256 MB RAM, 4 GB disk. It uses about 20 MB of RAM. The tunnel subnet is 10.8.0.x: the server is .1, the phone .2, and each new device gets the next number.
DNS: vpn.503mail.com needed no new record, because 503mail.com has a wildcard A record (DNS is on AWS Route 53). If the home IP changes, that record has to be updated by hand for now.
Where to run each command
- Proxmox host shell: in the Proxmox web UI click pve → Shell (or SSH to the host as root).
- Inside CT 302 (wireguard): Proxmox web UI → 302 (wireguard) → Console, or
pct enter 302from the host shell.
Rebuild, step by step
1. Make a working folder on the host
Proxmox host shell:
mkdir -p /root/wireguard-vpn && cd /root/wireguard-vpn
2. Create every file
Paste each block into the Proxmox host shell. Each one writes one file into /root/wireguard-vpn/.
create-ct.sh: runs on the host. Loads the WireGuard kernel module, creates the container, copies in the other two scripts, runs them and copies the phone's tunnel files back out.
cat > /root/wireguard-vpn/create-ct.sh <<'EOF'
#!/bin/bash
# Run on the Proxmox host: creates the WireGuard container and sets it up.
# Usage: bash create-ct.sh <ctid> <ip/cidr or dhcp>
# e.g. bash create-ct.sh 302 <free LAN address>/24
set -e
CTID=${1:?usage: create-ct.sh <ctid> <ip/cidr or dhcp>}
IPCIDR=${2:?usage: create-ct.sh <ctid> <ip/cidr or dhcp>}
HERE=$(cd "$(dirname "$0")" && pwd)
TEMPLATE=local:vztmpl/debian-12-standard_12.12-1_amd64.tar.zst
# WireGuard runs in the host kernel; the container only creates the interface.
modprobe wireguard
echo wireguard > /etc/modules-load.d/wireguard.conf
if [ "$IPCIDR" = dhcp ]; then
NET="ip=dhcp"
else
GW=$(ip -4 route show default | awk '{print $3; exit}')
NET="ip=$IPCIDR,gw=$GW"
fi
pveam list local | grep -q debian-12-standard_12.12-1 || pveam download local debian-12-standard_12.12-1_amd64.tar.zst
pct create "$CTID" "$TEMPLATE" \
--hostname wireguard --cores 1 --memory 256 --swap 256 \
--rootfs local-lvm:4 --net0 "name=eth0,bridge=vmbr0,$NET" \
--unprivileged 1 --features nesting=1 --onboot 1 --ostype debian
pct start "$CTID"
sleep 10
pct push "$CTID" "$HERE/setup-wg.sh" /root/setup-wg.sh
pct push "$CTID" "$HERE/add-peer.sh" /usr/local/bin/add-peer --perms 755
pct exec "$CTID" -- bash /root/setup-wg.sh
pct exec "$CTID" -- /usr/local/bin/add-peer phone 2
pct pull "$CTID" /etc/wireguard/clients/phone.zip "$HERE/phone.zip"
echo "Phone configs: $HERE/phone.zip"
EOF
setup-wg.sh: runs inside the container. Installs WireGuard, turns on IP forwarding, makes the server key and wg0.conf, and starts the service.
cat > /root/wireguard-vpn/setup-wg.sh <<'EOF_OUTER1'
#!/bin/bash
# Run inside the WireGuard container: installs WireGuard and writes the server config.
set -e
export DEBIAN_FRONTEND=noninteractive
VPN_NET=10.8.0 # tunnel subnet: server .1, peers .2 and up
PORT=51820
apt-get update -q
# --no-install-recommends: otherwise wireguard-tools pulls in a whole Linux kernel image
apt-get install -y -q --no-install-recommends wireguard-tools iptables qrencode zip
echo 'net.ipv4.ip_forward=1' > /etc/sysctl.d/99-wg.conf
sysctl -p /etc/sysctl.d/99-wg.conf
umask 077
mkdir -p /etc/wireguard/clients
cd /etc/wireguard
[ -f server.key ] || wg genkey > server.key
wg pubkey < server.key > server.pub
[ -f wg0.conf ] || cat > wg0.conf <<EOF
[Interface]
Address = $VPN_NET.1/24
ListenPort = $PORT
PrivateKey = $(cat server.key)
PostUp = iptables -t nat -A POSTROUTING -s $VPN_NET.0/24 -o eth0 -j MASQUERADE
PostDown = iptables -t nat -D POSTROUTING -s $VPN_NET.0/24 -o eth0 -j MASQUERADE
EOF
systemctl enable --now wg-quick@wg0
wg show
EOF_OUTER1
add-peer.sh: installed in the container as add-peer. Adds one device and makes a zip with its home and home-all tunnel files. The LAN subnet and router address are looked up when it runs.
cat > /root/wireguard-vpn/add-peer.sh <<'EOF_OUTER1'
#!/bin/bash
# Run inside the WireGuard container: adds a device and makes its two tunnel configs.
# Usage: add-peer <name> <number 2-254>
# <name>-home.conf only home-network traffic goes through the VPN
# <name>-home-all.conf all traffic goes through the VPN
# Both share one key, so only one can be on at a time.
set -e
NAME=${1:?usage: add-peer <name> <number>}
N=${2:?usage: add-peer <name> <number>}
VPN_NET=10.8.0
ENDPOINT=vpn.503mail.com:51820
cd /etc/wireguard
umask 077
LAN=$(ip -4 route show dev eth0 scope link | awk '{print $1; exit}')
GW=$(ip -4 route show default | awk '{print $3; exit}')
wg genkey > clients/$NAME.key
wg pubkey < clients/$NAME.key > clients/$NAME.pub
cat >> wg0.conf <<EOF
[Peer]
# $NAME
PublicKey = $(cat clients/$NAME.pub)
AllowedIPs = $VPN_NET.$N/32
EOF
wg set wg0 peer "$(cat clients/$NAME.pub)" allowed-ips "$VPN_NET.$N/32"
client() { # $1 = AllowedIPs, $2 = extra [Interface] line
cat <<EOF
[Interface]
PrivateKey = $(cat clients/$NAME.key)
Address = $VPN_NET.$N/32
$2
[Peer]
PublicKey = $(cat server.pub)
Endpoint = $ENDPOINT
AllowedIPs = $1
PersistentKeepalive = 25
EOF
}
mkdir -p /tmp/$NAME
client "$LAN, $VPN_NET.0/24" "" > /tmp/$NAME/home.conf
client "0.0.0.0/0" "DNS = $GW" > /tmp/$NAME/home-all.conf
(cd /tmp/$NAME && zip -q -j /etc/wireguard/clients/$NAME.zip home.conf home-all.conf)
rm -r /tmp/$NAME
echo "Made /etc/wireguard/clients/$NAME.zip (tunnels: home, home-all)"
EOF_OUTER1
3. Build the container
Proxmox host shell. Replace the address with a free static LAN address and its /24, the way you'd write it for any container (for example the one the router handed out before):
bash /root/wireguard-vpn/create-ct.sh 302 YOUR.LAN.IP.ADDRESS/24
It takes about a minute. The end of the output shows interface: wg0 with listening port: 51820 and one peer, then the path of phone.zip.
4. Forward the port on the router
On the Calix router (http://x.x.0.1 → Advanced → Security → Port Forwarding):
- Choose IP Address (not Device: the container uses a static address, so it isn't in the device list) and type the container's address.
- Protocol TCP/UDP (or UDP). WAN and LAN ports both 51820 to 51820. Remote IP All IP Addresses. Click Apply.
- Check the table at the bottom: the new row has to say UDP or TCP/UDP. My first try saved as TCP only and nothing could connect.
5. Put the tunnels on the phone
- Install WireGuard (by WireGuard Development Team) from the Play Store.
- Copy
/root/wireguard-vpn/phone.zipto the phone's Download folder, for example withadb push phone.zip /sdcard/Download/from the PC, or by email to yourself. - In WireGuard tap + → Import from file or archive → pick
phone.zip. Both tunnels, home and home-all, appear. - Switch home on. Android asks once to allow a VPN connection: tap OK. The switch may flip back off after that first OK; switch it on again.
- Delete
phone.zipfrom the phone and from the host afterwards, because it contains the private key.
Proxmox host shell, after the phone has it:
unlink /root/wireguard-vpn/phone.zip
6. Test
Turn off Wi-Fi on the phone so it uses mobile data, switch on home, and open http://x.x.0.195:8006 (the Proxmox login page) in the browser. Then check from the server that the phone connected from a mobile-carrier address. Proxmox host shell:
pct exec 302 -- wg show
7. Add another device later
Inside CT 302 (wireguard). Give each device its own name and the next free number (the phone is 2):
add-peer laptop 3
Then copy the zip out to the host. Proxmox host shell:
pct pull 302 /etc/wireguard/clients/laptop.zip /root/laptop.zip
Troubleshooting: where the logs are
WireGuard status (is the phone connected?)
- Where: live kernel state in CT 302 (wireguard). WireGuard itself logs almost nothing.
- Open: Proxmox host shell.
pct exec 302 -- wg show
- Look for: under the peer,
endpoint:(the phone's current public address),latest handshake: N seconds ago(under 2 minutes means connected) andtransfer:going up. Nolatest handshakeline at all means no packet ever arrived: check the router port forward (UDP!) and that the tunnel switch on the phone is really on.
Are packets arriving at all?
- Where: network traffic on the Proxmox bridge.
- Open: Proxmox host shell. Switch the phone's tunnel off and on while this runs.
timeout 20 tcpdump -ni vmbr0 udp port 51820
- Look for: lines from an outside address to the container on port 51820. Nothing at all means the router isn't forwarding. Packets in but no handshake means the keys don't match: re-import the zip.
Service log
- Where: the systemd journal in CT 302 (wireguard), service
wg-quick@wg0. - Open: Proxmox host shell.
pct exec 302 -- journalctl -u wg-quick@wg0 -n 50 --no-pager
- Look for:
ip link add wg0 type wireguardand theiptables ... MASQUERADEline, then nothing else (healthy).RTNETLINK answers: Not supportedmeans the host's WireGuard module isn't loaded: runmodprobe wireguardin the host shell and restart the service.
Phone side
- In the app, tap the tunnel name: the details page shows Latest handshake and Transfer at the bottom when it's connected.
Quick health check
Proxmox host shell:
pct exec 302 -- wg show wg0 latest-handshakes
Notes
- apt pulled in a whole Linux kernel.
wireguard-toolsrecommends a kernel image on Debian 12, which put 110 MB of useless kernel into the container on the first try.setup-wg.shuses--no-install-recommendsto avoid it. - The container needs the host's kernel module. The container only creates the interface; the WireGuard code runs in the Proxmox kernel.
create-ct.shloads it now and at every boot through/etc/modules-load.d/wireguard.conf. pct execdoesn't search/usr/local/bin, so from the host call/usr/local/bin/add-peerwith the full path. In the container console, plainadd-peerworks.- Testing from home Wi-Fi works too, because the Calix router sends traffic for the public IP back inside (hairpin NAT). The real test is still mobile data.
- Android turns wireless debugging off whenever the phone changes networks, so ADB to the phone stops after a Wi-Fi off/on test until it's switched back on.
- This replaced SoftEther. SoftEther does run on Linux too, but its management is a Windows GUI (or the
vpncmdcommand line), and the phone needs the OpenVPN app plus a hand-edited profile. WireGuard is one small app and one file to import.