4.5 KiB
VPS Reverse Proxy Migration
Full pattern for migrating from port-forwarded home server to VPS reverse proxy with Tailscale tunnel.
Architecture
Internet → VPS (nginx SSL, ports 80/443)
↳ Tailscale tunnel (41641 UDP)
↳ Home server (zero ports open to internet)
VPS Setup
1. Purchase VPS
OVH VPS Starter ($5/mo, 4GB RAM, 40GB NVMe, Ubuntu 26.04 LTS) or equivalent.
2. Install essentials
sudo apt update && sudo apt install -y nginx certbot python3-certbot-nginx curl ufw
sudo ufw allow 80/tcp && sudo ufw allow 443/tcp && sudo ufw allow 22/tcp
sudo ufw --force enable
3. Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sudo sh
sudo tailscale up --accept-routes --accept-dns=false
# Authenticate at the URL shown
Verify: tailscale status should show home server (rayserver) online.
4. Domain DNS (Porkbun)
Two A records:
*→ VPS IP@(root) → VPS IP
Via Porkbun API:
curl -X POST "https://api.porkbun.com/api/json/v3/dns/create/DOMAIN" \
-H "Content-Type: application/json" \
-d '{"apikey":"pk1_...","secretapikey":"sk1_...","name":"*","type":"A","content":"VPS_IP","ttl":300}'
Delete any default parking records (CNAME/ALIAS → uixie.porkbun.com) first.
5. Nginx reverse proxy template
Each service gets a subdomain config:
server {
listen 80;
server_name <name>.graj-media.com;
client_max_body_size 500M;
location / {
proxy_pass http://100.93.253.36:<port>;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_buffering off;
}
}
For services behind home nginx SSL (Mealie, ShopProQuote), use HTTPS proxy:
proxy_pass https://100.93.253.36:3449;
proxy_ssl_verify off;
proxy_ssl_server_name on;
proxy_ssl_name graj-media.com;
6. SSL via Let's Encrypt
Single cert for all subdomains:
sudo certbot --nginx -d graj-media.com \
-d immich.graj-media.com -d paperless.graj-media.com \
-d ha.graj-media.com -d shopproquote.graj-media.com \
-d mealie.graj-media.com -d audiobookshelf.graj-media.com
Auto-renews via systemd timer.
7. LLM proxy (ShopProQuote)
Add /llm/ location block to ShopProQuote config:
location /llm/ {
proxy_pass http://100.93.253.36:11434/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_read_timeout 120;
}
8. Home Assistant trusted proxies
HA behind VPS reverse proxy returns 400 without trusted proxy config:
http:
use_x_forwarded_for: true
trusted_proxies:
- 127.0.0.1
- 100.86.68.23 # VPS Tailscale IP
Restart: docker restart homeassistant
Verification
# Check all services from VPS
for url in \
https://graj-media.com \
https://immich.graj-media.com \
https://paperless.graj-media.com \
https://ha.graj-media.com \
https://shopproquote.graj-media.com \
https://mealie.graj-media.com \
https://audiobookshelf.graj-media.com; do
curl -skL -o /dev/null -w "%{http_code} $url\n" $url
done
Close home router ports
After verifying all services work via VPS, delete ALL port forwarding rules on the ASUS RT-AX82U router. Home server should have zero ports exposed.
# Verify from outside
for port in 80 443 2283 3443 3444 3445 3446 3447 3448 3449 3450 8010 8123 13378; do
timeout 2 bash -c "echo >/dev/tcp/HOME_PUBLIC_IP/$port" 2>/dev/null && echo "PORT $port OPEN" || echo "PORT $port closed"
done
Pitfalls
- Bash variable expansion in heredocs. When writing nginx configs via
ssh host "sudo tee << 'EOF' ...",$http_upgradeand$hostare expanded by the local shell despite the quoted EOF marker. Workaround: write to temp file locally, scp, or use sed to fix after writing. - Certbot namespace collision. Old certbot configs for DuckDNS domains conflict with new ones. Delete all old certs and configs before re-issuing for the new domain.
- Mealie direct port is localhost-only. Mealie Docker binds
127.0.0.1:9925, unreachable via Tailscale. Proxy through the home nginx SSL endpoint instead (https://100.93.253.36:3449). - DuckDNS 5-domain limit. Can't use subdomain-per-service with DuckDNS. A proper domain ($11/yr) with wildcard DNS is required.