Files
hermes-config/skills/self-hosting/docker-service-deployment/references/vps-migration.md
T
2026-07-12 10:17:17 -04:00

152 lines
4.5 KiB
Markdown

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