Files
2026-07-12 10:17:17 -04:00

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_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.