# 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 <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:
```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.
