# 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 ```bash 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 ```bash 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: ```bash 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: ```nginx server { listen 80; server_name .graj-media.com; client_max_body_size 500M; location / { proxy_pass http://100.93.253.36:; 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: ```nginx 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: ```bash 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: ```nginx 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: ```yaml http: use_x_forwarded_for: true trusted_proxies: - 127.0.0.1 - 100.86.68.23 # VPS Tailscale IP ``` Restart: `docker restart homeassistant` ## Verification ```bash # 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. ```bash # 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_upgrade` and `$host` are 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.