This is the follow-up to the intro post where I outlined the problem. If you haven’t read that one, the short version: I’m switching to Cityside Fiber, which uses CGNAT. No public IPv4 address means no inbound connections — no VPN, no self-hosted services, nothing. The fix is an Oracle Cloud VPS as a permanent public-facing endpoint, with WireGuard tunneling everything back to the home network.
Here’s how I actually built it.
Prerequisites — What This Post Assumes
Your domain is already on Cloudflare. This guide uses Cloudflare for DNS management throughout — creating A records, setting proxy status, and generating the API token that SWAG uses for Let’s Encrypt certificate validation. If your domain is still on your registrar’s nameservers, transfer it to Cloudflare before starting. It’s free, takes about 15 minutes, and you’ll need it for everything that follows.
The Cloudflare dependency is worth calling out because it does two things here, not just one. The obvious one is DNS records. The less obvious one is that SWAG’s certificate validation happens through Cloudflare’s DNS API — this means your VPS doesn’t need to be publicly accessible on port 80 to get a valid Let’s Encrypt certificate. It’s a significant advantage over HTTP-based validation, especially during initial setup.
If you want to go further with security, Cloudflare’s WAF (Web Application Firewall) is available on the free plan and can be applied to your proxied subdomains. It adds bot protection and basic rule-based filtering in front of SWAG without any configuration on the VPS itself. That’s outside the scope of this post, but it’s worth knowing the option is there.
Before You Start — You Need a Second Device on Your Home LAN
This is the part most write-ups skip, and it will save you a lot of frustration.
Your home router probably won’t work as the WireGuard site-to-site peer. Many consumer routers (including mine, a TP-Link BE9300) support WireGuard in their VPN Client section, but that feature is for routing your LAN devices out through a VPN — like Mullvad or NordVPN. It cannot accept inbound traffic from a WireGuard tunnel and forward it to your LAN devices. The tunnel establishes fine, handshakes look clean, but nothing on your network is actually reachable. I confirmed this the hard way.
What you need is a separate device on your home LAN that initiates the WireGuard tunnel outbound to the VPS. This device acts as the gateway. Options include:
- A NAS or home server running Linux — this is what I use; Unraid has native WireGuard support built in
- A Raspberry Pi running WireGuard
- A mini PC running any Linux distro
- OPNsense or pfSense running as your actual router/firewall
- Any always-on Linux machine on the LAN
The key requirement: it needs to be on 24/7 and run WireGuard in client mode (initiating outbound). CGNAT blocks inbound connections, not outbound ones. Once your gateway device establishes the tunnel outbound to the VPS, traffic flows both ways through it.
If you don’t have any of these, a $35 Raspberry Pi Zero 2 W running Raspberry Pi OS handles this fine for a home lab.
Overview of What Gets Built
- Oracle Cloud VPS — WireGuard server + SWAG reverse proxy
- WireGuard UI — peer management and config generation, runs as a Docker container on the VPS
- SWAG — Nginx reverse proxy with automatic Let’s Encrypt SSL via Cloudflare DNS validation, also Docker on the VPS
- WireGuard on a home LAN device — initiates site-to-site tunnel outbound, acts as gateway for the home subnet
- Static route on your router — tells the router to send VPN client traffic through the LAN gateway device
The end result: VPN clients connect to the VPS and can reach everything on the home network. SWAG proxies web services through the tunnel to home LAN containers. All of this works whether or not the home connection has a real public IPv4 address.
Step 1: Oracle Cloud VPS
Spin up an Oracle Free Tier VM running Ubuntu 24.04. Download your private key when prompted — you cannot recover it later.
SSH in and update first:
sudo apt update && sudo apt upgrade -y
Install Docker using the official documentation: https://docs.docker.com/engine/install/ubuntu/
Step 2: Docker Compose Setup
Create your working directory:
sudo mkdir -p /opt/edge-stack
cd /opt/edge-stack
Create the compose file:
sudo nano docker-compose.yml
Paste the following. The important part is the network section with defined fixed IPs — more on why this matters later.
services:
wg-ui:
image: ngoduykhanh/wireguard-ui:latest
container_name: wg-ui
restart: unless-stopped
cap_add:
- NET_ADMIN
- SYS_MODULE
environment:
- SERVER=auto
- SERVER_PORT=51820
volumes:
- ./config:/etc/wireguard
- ./db:/app/db
- /lib/modules:/lib/modules
ports:
- "51820:51820/udp"
networks:
edge:
ipv4_address: 172.20.0.2
swag:
image: lscr.io/linuxserver/swag:latest
container_name: swag
cap_add:
- NET_ADMIN
environment:
- PUID=1000
- PGID=1000
- TZ=${TZ}
- URL=${URL}
- SUBDOMAINS=${SUBDOMAINS}
- VALIDATION=dns
- DNSPLUGIN=cloudflare
- EMAIL=${EMAIL}
- ONLY_SUBDOMAINS=true
volumes:
- ./swag:/config
restart: unless-stopped
ports:
- "443:443"
networks:
edge:
ipv4_address: 172.20.0.3
networks:
edge:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/24
gateway: 172.20.0.1
Create the environment file:
sudo nano .env
TZ=America/Los_Angeles
URL=yourdomain.com
SUBDOMAINS=gateway,wg,overseerr,tautulli,immich
[email protected]
Why fixed Docker IPs matter: Docker assigns IPs dynamically by default. If you restart or recreate containers, the wg-ui container could get a different IP. Every startup script and route that references that IP breaks silently. Defining a subnet with fixed addresses in docker-compose.yml prevents this entirely.
Step 3: Cloudflare API Token for SWAG
SWAG uses Cloudflare DNS validation to get Let’s Encrypt certificates. This works even without a web server being publicly accessible on port 80, which is why it’s the right choice here.
Create a Cloudflare API token with Zone:DNS:Edit permissions for your domain. Then:
sudo nano ./swag/dns-conf/cloudflare.ini
dns_cloudflare_api_token = your_token_here
Step 4: Firewall — UFW and Oracle Security Rules
On the VPS:
sudo apt install ufw -y
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 443/tcp
sudo ufw allow 51820/udp
sudo ufw enable
In the Oracle Cloud console, add ingress rules to your Security List:
- TCP 443 from 0.0.0.0/0
- UDP 51820 from 0.0.0.0/0
- TCP 22 from 0.0.0.0/0 (SSH)
Step 5: Start the Containers
cd /opt/edge-stack
sudo docker compose up -d
Verify both are running:
sudo docker ps
SWAG will request certificates on first startup. Give it 30–60 seconds, then check logs:
sudo docker logs swag --tail 50
Look for Server ready — that confirms certificates issued successfully.
Step 6: WireGuard UI — Configure the Server
Open a browser and go to http://YOUR_VPS_IP:5000 to access WireGuard UI. Set a username and password.
Navigate to Wireguard Server and configure:
- Server Interface Addresses:
10.8.0.1/24 - Listen Port:
51820 - MTU:
1420(important — explained below)
PostUp Script:
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; iptables -A FORWARD -i wg0 -j ACCEPT; iptables -A FORWARD -o wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o wg0 ! -s 10.8.0.0/24 -j MASQUERADE; iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360
PostDown Script:
iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE; iptables -D FORWARD -i wg0 -j ACCEPT; iptables -D FORWARD -o wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o wg0 ! -s 10.8.0.0/24 -j MASQUERADE; iptables -t mangle -D FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1360
Save, then apply the config.
A note on MTU and that last iptables rule: WireGuard adds roughly 60 bytes of overhead to every packet it encrypts. If the inner packet is too large, the outer UDP datagram exceeds the 1500-byte network MTU, and since WireGuard sets the Don’t Fragment bit, those packets get silently dropped. The symptom is that small responses work fine (redirects, API health checks) but full page loads hang indefinitely. Setting MTU to 1420 and clamping TCP MSS to 1360 ensures packets fit cleanly through the tunnel. The math: 1360 TCP payload + 40 TCP/IP headers = 1400 inner + 60 WireGuard overhead = 1460 outer, comfortably under 1500.
Step 7: Create WireGuard Peers
For the home LAN gateway device:
In WireGuard UI, click New Client:
- Name:
Unraid-SiteToSite(or whatever your device is) - IP Allocation:
10.8.0.10/32 - Allowed IPs:
10.8.0.10/32 - Extra Allowed IPs:
192.168.11.0/24← replace with your home LAN subnet
That last field — Extra Allowed IPs — is the critical one. It tells the VPS that your home LAN subnet lives behind this peer. Without it, the VPS has no route to your home network.
Save and download the config file. You’ll need it in the next step.
For VPN client devices (iPhone, laptop, etc.):
Create separate peers for each device.
For split tunnel (recommended — only home LAN and VPN traffic goes through, internet stays local):
- AllowedIPs on the client:
10.8.0.0/24, 192.168.11.0/24
For full tunnel (all traffic through VPS):
- AllowedIPs on the client:
0.0.0.0/0
Step 8: Configure the Home LAN Gateway Device
If using Unraid:
Go to Settings → VPN Manager → Add Tunnel → Import the config file you downloaded.
Change Peer type of access to LAN to LAN access. This is the setting that makes Unraid initiate the connection outbound — compatible with CGNAT.
Fill in:
- Peer tunnel address:
10.8.0.1 - Peer endpoint:
gateway.yourdomain.com:51820 - Peer allowed IPs:
10.8.0.0/24 - Persistent keepalive:
25
Peer allowed IPs on the Unraid side is 10.8.0.0/24 only — not 0.0.0.0/0, not your home LAN subnet. This is the split tunnel on the site-to-site leg. Home LAN internet traffic continues out through the router normally.
Apply and enable the tunnel.
If using a Raspberry Pi or other Linux device:
sudo apt install wireguard -y
Copy the downloaded config file to /etc/wireguard/wg0.conf and edit the [Peer] section so AllowedIPs = 10.8.0.0/24, then enable it:
sudo systemctl enable wg-quick@wg0
sudo systemctl start wg-quick@wg0
Step 9: Add the Static Route on Your Router
This step is easy to miss and causes confusing failures. When a home LAN device responds to a VPN client (IP in the 10.8.0.x range), that response needs to go back through the WireGuard gateway device — not out the WAN to the internet.
On your router, add a static route:
- Destination:
10.8.0.0 - Subnet Mask:
255.255.255.0 - Gateway: LAN IP of your WireGuard gateway device (e.g.,
192.168.11.25) - Interface: LAN
Without this, VPN clients can receive initial responses but subsequent packets from LAN devices are dropped. Connections appear to half-work or time out unpredictably.
Step 10: Verify the Tunnel
On the VPS:
sudo docker exec -it wg-ui wg show
Look for your home LAN gateway peer. You should see:
latest handshake:— within the last minutetransfer:— bytes in both directions
Then verify the VPS can actually reach your home LAN:
sudo docker exec -it wg-ui ping -c 3 192.168.11.25
If that returns replies, the full routing chain is functional.
Step 11: Startup Script — Keeping Routes Persistent
Docker containers lose routing changes on restart. Create a startup script that re-applies everything after each reboot:
sudo nano /opt/edge-stack/start-wg-ui-tunnel.sh
#!/bin/bash
sleep 15
docker exec wg-ui wg-quick up wg0 || true
ip route add 192.168.11.0/24 via 172.20.0.2 2>/dev/null || true
docker exec swag ip route add 192.168.11.0/24 via 172.20.0.2 2>/dev/null || true
sudo chmod +x /opt/edge-stack/start-wg-ui-tunnel.sh
Create a systemd service to run it at boot:
sudo nano /etc/systemd/system/start-wg-ui-tunnel.service
[Unit]
Description=Start WireGuard tunnel inside wg-ui container
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/opt/edge-stack/start-wg-ui-tunnel.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable start-wg-ui-tunnel.service
Important: this script runs at boot, not on docker compose down/up. If you restart your containers, re-run the script manually:
sudo /opt/edge-stack/start-wg-ui-tunnel.sh
Step 12: SWAG Reverse Proxy Configs
For each service you want to proxy, create a subdomain conf in /opt/edge-stack/swag/nginx/proxy-confs/.
Example for Overseerr running on your home LAN at 192.168.11.25:5055:
sudo nano /opt/edge-stack/swag/nginx/proxy-confs/overseerr.subdomain.conf
server {
listen 443 ssl;
server_name overseerr.*;
include /config/nginx/ssl.conf;
client_max_body_size 0;
location / {
include /config/nginx/proxy.conf;
include /config/nginx/resolver.conf;
set $upstream_app 192.168.11.25;
set $upstream_port 5055;
set $upstream_proto http;
proxy_pass $upstream_proto://$upstream_app:$upstream_port;
}
}
Repeat for each service, changing the server_name and upstream port.
For Immich specifically, add proxy_request_buffering off; before the location block — it’s required for photo and video uploads to work properly.
After adding configs, test and reload nginx:
sudo docker exec swag nginx -t && sudo docker exec swag nginx -s reload
Step 13: Cloudflare DNS
For each subdomain, create an A record in Cloudflare pointing to your VPS public IP.
Proxy settings matter:
gateway.yourdomain.com— Grey cloud (DNS only). This is your WireGuard UDP endpoint. Cloudflare’s proxy is TCP only. If this ever gets set to orange cloud, all VPN connections silently fail.- Everything else (
wg,overseerr,tautulli, etc.) — Orange cloud (proxied). SWAG handles SSL directly with its Let’s Encrypt cert. The Cloudflare proxy is additive — it hides your VPS IP, provides DDoS mitigation, and enables the WAF.
Optional: Cloudflare WAF
For orange cloud subdomains, Cloudflare’s WAF is available on the free plan under Security → WAF. The free tier includes managed rules that block common attack patterns (SQLi, XSS, known bad bots) before requests reach SWAG. For publicly exposed services, enabling the free managed ruleset is a low-effort improvement with no downside. If a legitimate request gets blocked, check Security → Events and create an exception.
Verifying the Full Chain
Test that SWAG can reach your home LAN services through the tunnel:
sudo docker exec swag curl -I http://192.168.11.25:5055
You should get an HTTP response (307, 200, 303 — anything except a timeout or connection refused). If it times out, run the startup script and check that routes are in place:
ip route show | grep 192.168.11
sudo docker exec swag ip route show | grep 192.168.11
Both should show a route to 192.168.11.0/24 via 172.20.0.2.
Why This Works Under CGNAT
CGNAT blocks inbound connections to your home IP. It does not block outbound connections. The home LAN gateway device (Unraid, Raspberry Pi, etc.) initiates the WireGuard handshake outbound to the VPS. Once that tunnel is established, the VPS can push traffic back through it in both directions. From that point, it behaves like any other WireGuard site-to-site setup — the CGNAT becomes irrelevant.
This is also why a VPN client profile with AllowedIPs = 0.0.0.0/0 (full tunnel) works correctly: the VPN client connects to the VPS outbound, traffic flows back through the VPS to the home tunnel, and responses return the same way. No inbound connection to the home network ever happens.
Securing the WireGuard UI
The WireGuard UI at wg.yourdomain.com is a management panel that can create, modify, and revoke VPN peers — meaning full control over who has access to your home network. It needs to be treated accordingly.
Exposing it publicly via SWAG is a convenience trade-off. The alternative — only accessing it while already connected to the VPN — is more secure but adds friction every time you need to add a peer or check tunnel status.
Minimum baseline if exposing it publicly:
- Set a strong, unique password in the WireGuard UI settings — not something reused elsewhere
- Access is HTTPS only via SWAG — the management port (5000) is not exposed directly
wg.yourdomain.comshould be orange cloud (Cloudflare proxied) — this hides the VPS IP and lets Cloudflare absorb probes before they reach SWAG
If you want a stronger posture — Cloudflare Zero Trust Access:
Cloudflare Zero Trust (free tier) lets you put an identity gate in front of any proxied subdomain. Anyone who hits wg.yourdomain.com gets redirected to a Cloudflare login page first, where they authenticate with an email OTP, Google, GitHub, or similar. Only after that do they reach SWAG and the WireGuard UI login.
This is a meaningful upgrade because it eliminates the attack surface of the WireGuard UI login form entirely from the public internet — brute force attempts, credential stuffing, and any future vulnerabilities in WireGuard UI’s auth layer are all blocked before the request reaches your VPS.
Setup is in the Cloudflare Zero Trust dashboard: Access → Applications → Add an application → Self-hosted. Point it at wg.yourdomain.com, set your access policy, and it’s active within a few minutes. No changes needed on the VPS side.
Whether you add Zero Trust or not, the SWAG + Cloudflare proxy combination is already a significant improvement over exposing the management port directly. But for a panel that controls access to your entire home network, the extra layer is worth the 15 minutes it takes to configure.
Final Notes
A few things I would do from the start if doing this again:
Define fixed Docker IPs immediately. I didn’t do this early enough and spent time debugging a startup script that silently stopped working when container IPs changed after a compose restart.
Don’t use your home router as the WireGuard peer. Spend 20 minutes up front confirming whether your router actually supports inbound tunnel forwarding before going down that path. Most consumer firmware VPN Client modes do not.
Test SWAG → LAN service connectivity before touching DNS. Run the curl test from inside the SWAG container before creating any Cloudflare records. If the route isn’t working, changing DNS will just make troubleshooting harder.
If page loads work but full content hangs — it’s MTU. Small API responses and redirects passing while full page loads time out is the exact signature of WireGuard MTU fragmentation. The PostUp script covers this, but if you’re building on a different base, add the MSS clamp rule.
Infrastructure: Oracle Cloud Free Tier (AMD) · Ubuntu 24.04 · Docker Compose V2 · WireGuard UI by ngoduykhanh · SWAG by linuxserver · Cloudflare DNS


