initial commit
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
---
|
||||
name: vps-reverse-proxy
|
||||
description: "Route all self-hosted services through a cheap VPS reverse proxy via Tailscale — zero ports open on home router, SSL on the VPS, services stay at home."
|
||||
version: 1.1.0
|
||||
author: ray
|
||||
platforms: [linux]
|
||||
created_by: "agent"
|
||||
category: devops
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [vps, reverse-proxy, tailscale, nginx, ssl, duckdns, self-hosting]
|
||||
---
|
||||
|
||||
# VPS Reverse Proxy for Self-Hosted Services
|
||||
|
||||
Route all self-hosted services (Immich, Paperless, Home Assistant, Mealie, etc.) through a cheap cloud VPS. The VPS acts as the public entry point — nginx with Let's Encrypt SSL — and tunnels traffic to the home server via Tailscale. Home router has zero ports open to the internet.
|
||||
|
||||
## When to use
|
||||
|
||||
- User has multiple self-hosted services (10+ nginx sites) exposed via port forwarding on their home router
|
||||
- User wants to hide their home IP, reduce attack surface, and absorb DDoS at the VPS
|
||||
- User already has Tailscale connecting home server and wants to extend it
|
||||
- User has a cheap VPS ($4-6/mo) with 4GB+ RAM, 30GB+ disk, 1TB+ bandwidth
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Internet → VPS (ports 80 + 443 only, nginx SSL)
|
||||
↳ Tailscale WireGuard tunnel (41641 UDP)
|
||||
↳ Home server (zero ports open to internet)
|
||||
↳ Immich:2283, Paperless:8010, HA:8123, Mealie:3449, etc.
|
||||
```
|
||||
|
||||
All service data stays on the home server. The VPS only runs nginx + certbot + Tailscale. Bandwidth: a VPS with 32TB/mo handles Immich photo browsing (23K assets) and all other services without issue.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- VPS with Ubuntu (OVH, Contabo, Hetzner, DigitalOcean — any provider)
|
||||
- Root or sudo SSH access to the VPS
|
||||
- Tailscale account with home server already on the tailnet
|
||||
- DuckDNS domain (or any domain with API-updatable DNS)
|
||||
|
||||
## Step 1: Provision VPS
|
||||
|
||||
Order the cheapest instance with 4GB+ RAM. OVH VPS Starter or Contabo VPS S are ~$5/mo. Note the public IPv4 address.
|
||||
|
||||
## Step 2: Initial VPS setup
|
||||
|
||||
```bash
|
||||
# SSH in as provided user (ubuntu on OVH cloud images)
|
||||
ssh ubuntu@<vps-ip>
|
||||
|
||||
# Install essentials
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y nginx certbot python3-certbot-nginx curl ufw
|
||||
|
||||
# Firewall — only 22, 80, 443
|
||||
sudo ufw allow 22/tcp
|
||||
sudo ufw allow 80/tcp
|
||||
sudo ufw allow 443/tcp
|
||||
sudo ufw --force enable
|
||||
```
|
||||
|
||||
## Step 3: Tailscale
|
||||
|
||||
```bash
|
||||
curl -fsSL https://tailscale.com/install.sh | sudo sh
|
||||
sudo tailscale up --accept-routes --accept-dns=false
|
||||
```
|
||||
|
||||
This prints an auth URL. Open it in a browser already logged into Tailscale. After auth, verify the home server is reachable:
|
||||
|
||||
```bash
|
||||
tailscale status
|
||||
# Should show: rayserver 100.93.253.36 linux -
|
||||
|
||||
curl http://100.93.253.36:2283/api/server/ping
|
||||
# {"res":"pong"}
|
||||
```
|
||||
|
||||
### Tailscale flags
|
||||
|
||||
- `--accept-routes`: lets VPS reach subnets advertised by other tailnet nodes
|
||||
- `--accept-dns=false`: prevent Tailscale from overwriting `/etc/resolv.conf` (keep VPS DNS for Let's Encrypt challenges)
|
||||
|
||||
## Step 4: DNS
|
||||
|
||||
### Option A: Porkbun domain (recommended for >5 services)
|
||||
|
||||
DuckDNS caps at 5 entries. **IMPORTANT:** The DuckDNS update cron job must target the VPS IP explicitly, not auto-detect (which records the home IP). See `references/duckdns-vps-ip.md`.. For 6+ services, use a proper domain (~$11/yr at Porkbun). Porkbun supports wildcard DNS — one `*.graj-media.com` A record covers all subdomains.
|
||||
|
||||
**Porkbun API setup** — requires three things:
|
||||
1. API key from Porkbun → Account → API Access
|
||||
2. Secret API key from the same page
|
||||
3. Domain API access enabled: Porkbun → Account Settings → "Allow API access for all domains"
|
||||
|
||||
**DNS creation via API** — first delete any parking records, then add A records:
|
||||
|
||||
```bash
|
||||
# Delete parking/default records (CNAME/ALIAS pointing to uixie.porkbun.com)
|
||||
curl -sk -X POST "https://api.porkbun.com/api/json/v3/dns/retrieve/graj-media.com" \
|
||||
-d '{"apikey":"pk1_...","secretapikey":"sk1_..."}' | jq '.records[] | select(.type=="CNAME" or .type=="ALIAS") | .id'
|
||||
|
||||
# Delete each
|
||||
curl -sk -X POST "https://api.porkbun.com/api/json/v3/dns/delete/graj-media.com/<id>" \
|
||||
-d '{"apikey":"pk1_...","secretapikey":"sk1_..."}'
|
||||
|
||||
# Add wildcard A record
|
||||
curl -sk -X POST "https://api.porkbun.com/api/json/v3/dns/create/graj-media.com" \
|
||||
-d '{"apikey":"pk1_...","secretapikey":"sk1_...","name":"*","type":"A","content":"51.81.84.34","ttl":300}'
|
||||
|
||||
# Add root A record
|
||||
curl -sk -X POST "https://api.porkbun.com/api/json/v3/dns/create/graj-media.com" \
|
||||
-d '{"apikey":"pk1_...","secretapikey":"sk1_...","name":"","type":"A","content":"51.81.84.34","ttl":300}'
|
||||
```
|
||||
|
||||
**Pitfall:** Porkbun returns `"conflict"` errors if parking/default CNAME/ALIAS records exist. Delete them first, then add A records.
|
||||
|
||||
**Pitfall:** If the API returns `"Domain is not opted in to API access"`, go to Porkbun → Account Settings and enable the global API access toggle (separate from the API key generation page).
|
||||
|
||||
### Option B: DuckDNS (free, max 5 entries)
|
||||
|
||||
DuckDNS does NOT support dotted subdomains (`immich.grajmedia.duckdns.org`). Use the **hyphen format** instead: `immich-grajmedia.duckdns.org`.
|
||||
|
||||
On [duckdns.org](https://duckdns.org), add all domains comma-separated in the domains field:
|
||||
|
||||
```
|
||||
grajmedia,immich-grajmedia,paperless-grajmedia,ha-grajmedia,shopproquote-grajmedia,mealie-grajmedia,audiobookshelf-grajmedia
|
||||
```
|
||||
|
||||
Set the IP to the VPS public IP. Update. Verify:
|
||||
|
||||
```bash
|
||||
dig +short immich-grajmedia.duckdns.org @1.1.1.1
|
||||
# Should return VPS IP
|
||||
```
|
||||
|
||||
## Step 5: nginx reverse proxy configs
|
||||
|
||||
Each service gets its own nginx server block with its own DuckDNS subdomain. Port mappings from Tailscale IP to home server:
|
||||
|
||||
| Service | Home server port | VPS domain |
|
||||
|---------|-----------------|------------|
|
||||
| Immich | 2283 (direct) | immich-grajmedia.duckdns.org |
|
||||
| Paperless | 8010 (direct) | paperless-grajmedia.duckdns.org |
|
||||
| Home Assistant | 8123 (direct) | ha-grajmedia.duckdns.org |
|
||||
| Mealie | 3449 (home nginx SSL) | mealie-grajmedia.duckdns.org |
|
||||
| Audiobookshelf | 13378 (direct) | audiobookshelf-grajmedia.duckdns.org |
|
||||
| ShopProQuote | 443 (home nginx SSL) | shopproquote-grajmedia.duckdns.org |
|
||||
|
||||
### Template for direct-HTTP services
|
||||
|
||||
Most Docker services expose HTTP on a port (Immich, Paperless, HA, Audiobookshelf):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name <service>-grajmedia.duckdns.org;
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Template for home-nginx-SSL services (DuckDNS)
|
||||
|
||||
Some services sit behind the home server's nginx with SSL only (Mealie on 3449, ShopProQuote on 443). The VPS must proxy via HTTPS. For DuckDNS subdomains (`<service>-grajmedia.duckdns.org`), the home server nginx only knows the DuckDNS name, so the VPS must hardcode the Host header and SNI name:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name <service>-grajmedia.duckdns.org;
|
||||
client_max_body_size 500M;
|
||||
|
||||
location / {
|
||||
proxy_pass https://100.93.253.36:<ssl_port>;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host grajmedia.duckdns.org;
|
||||
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_ssl_verify off;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name grajmedia.duckdns.org;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The critical directives for DuckDNS-style HTTPS proxying to home nginx:
|
||||
- `proxy_ssl_verify off` — home nginx uses Let's Encrypt for `grajmedia.duckdns.org`, not the Tailscale IP
|
||||
- `proxy_ssl_server_name on` + `proxy_ssl_name grajmedia.duckdns.org` — SNI hostname the home nginx expects
|
||||
- `proxy_set_header Host grajmedia.duckdns.org` — hardcoded to match the home nginx server_name (DuckDNS subdomains are NOT known to home nginx; only the base DuckDNS domain is)
|
||||
|
||||
### Template for home-nginx-SSL services (custom domain)
|
||||
|
||||
When using a custom domain like `graj-media.com` with per-subdomain Let's Encrypt certs on the VPS, forward the original Host header. Each subdomain (`mealie.graj-media.com`, `vault.graj-media.com`, `plex.graj-media.com`) must ALSO be added as a `server_name` on the home server's nginx config:
|
||||
|
||||
**VPS nginx:**
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name <service>.graj-media.com;
|
||||
client_max_body_size 100M;
|
||||
|
||||
location / {
|
||||
proxy_pass https://100.93.253.36:<home_port>;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host; # forward original subdomain
|
||||
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_ssl_verify off;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name grajmedia.duckdns.org; # home nginx cert SAN
|
||||
proxy_read_timeout 86400;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/<service>.graj-media.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/<service>.graj-media.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
```
|
||||
|
||||
**Home server nginx — must include the subdomain in server_name:**
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen <port> ssl;
|
||||
server_name grajmedia.duckdns.org <service>.graj-media.com; # ← CRITICAL
|
||||
# ... rest of the config
|
||||
}
|
||||
```
|
||||
|
||||
Key differences from DuckDNS template:
|
||||
- `proxy_set_header Host $host;` — forward the original subdomain, NOT a hardcoded value. The home nginx matches it via `server_name`.
|
||||
- Each subdomain gets its own Let's Encrypt cert on the VPS (certbot `-d <service>.graj-media.com`)
|
||||
- The home server's `server_name` must include `<service>.graj-media.com` — always verify this when adding a new subdomain.
|
||||
|
||||
Enable each site and test:
|
||||
|
||||
```bash
|
||||
sudo ln -sf /etc/nginx/sites-available/<service> /etc/nginx/sites-enabled/
|
||||
sudo rm -f /etc/nginx/sites-enabled/default
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Test each before DNS propagates:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}' -H 'Host: <service>-grajmedia.duckdns.org' http://localhost/
|
||||
```
|
||||
|
||||
## Step 6: Let's Encrypt SSL
|
||||
|
||||
After DNS propagates for all subdomains, provision SSL for all at once:
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx --non-interactive --agree-tos --email <email> \
|
||||
-d grajmedia.duckdns.org \
|
||||
-d immich-grajmedia.duckdns.org \
|
||||
-d paperless-grajmedia.duckdns.org \
|
||||
-d ha-grajmedia.duckdns.org \
|
||||
-d shopproquote-grajmedia.duckdns.org \
|
||||
-d mealie-grajmedia.duckdns.org \
|
||||
-d audiobookshelf-grajmedia.duckdns.org
|
||||
```
|
||||
|
||||
Certbot auto-renews. It edits the nginx configs to add `listen 443 ssl` and the cert paths.
|
||||
|
||||
### Adding a new subdomain to an existing multi-domain cert
|
||||
|
||||
When you already have a cert covering N subdomains and need to add one more (e.g. adding `gitea.graj-media.com` to an existing `graj-media.com` cert), use `--expand`:
|
||||
|
||||
```bash
|
||||
# 1. Create the nginx server block on the VPS first (certbot needs it to exist)
|
||||
ssh ubuntu@<vps-ip> 'sudo tee /etc/nginx/sites-available/<new-service> > /dev/null << '\''EOF'\''
|
||||
server {
|
||||
server_name <new-service>.graj-media.com;
|
||||
client_max_body_size 512M;
|
||||
|
||||
location / {
|
||||
proxy_pass https://100.93.253.36:<home_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_ssl_verify off;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/graj-media.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/graj-media.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
EOF
|
||||
sudo ln -sf /etc/nginx/sites-available/<new-service> /etc/nginx/sites-enabled/'
|
||||
|
||||
# 2. Expand the existing cert — list ALL existing domains PLUS the new one
|
||||
ssh ubuntu@<vps-ip> 'sudo certbot certonly --nginx --non-interactive --agree-tos \
|
||||
--email <email> \
|
||||
--cert-name graj-media.com \
|
||||
-d graj-media.com \
|
||||
-d existing1.graj-media.com \
|
||||
-d existing2.graj-media.com \
|
||||
-d existing3.graj-media.com \
|
||||
-d <new-service>.graj-media.com'
|
||||
|
||||
# 3. Reload nginx on the VPS
|
||||
ssh ubuntu@<vps-ip> 'sudo systemctl reload nginx'
|
||||
|
||||
# 4. Add the new subdomain to the HOME server's nginx server_name
|
||||
# (on the home server, edit the service's nginx config)
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
**Pitfall:** The `--expand` flag is required when adding domains to an existing cert, NOT `--force-renewal` or bare `certonly`. Without `--expand`, certbot creates a separate cert file instead of extending the existing one. The nginx `ssl_certificate` paths remain the same (certbot updates the live symlink), so no config changes needed.
|
||||
|
||||
**Pitfall:** List ALL existing domains every time. Omitting one drops it from the renewed cert. Use `openssl x509 -in /etc/letsencrypt/live/<cert-name>/fullchain.pem -noout -text | grep DNS:` to see the current SANs before expanding.
|
||||
|
||||
**Pitfall:** The VPS nginx server block for the new subdomain MUST exist before running certbot — certbot uses the nginx plugin to verify the domain. Create the config first (it can point to a non-existent backend for a moment), run certbot (which adds the SSL directives), then ensure the proxy config is correct.
|
||||
|
||||
**Pitfall:** For custom domains (graj-media.com), the VPS cert is a multi-domain cert (NOT a wildcard). Each new subdomain requires an explicit certbot expand. DNS wildcard records (`*.graj-media.com`) handle routing but NOT certificate coverage — certbot must still list every subdomain in the `-d` flags.
|
||||
|
||||
## Step 8: ShopProQuote LLM proxy
|
||||
|
||||
If ShopProQuote calls `/llm/` to a local Ollama on the home server, add a location block to the ShopProQuote nginx config on the VPS:
|
||||
|
||||
```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;
|
||||
}
|
||||
```
|
||||
|
||||
## Step 9: Static sites (no Docker backend)
|
||||
|
||||
For static HTML/CSS/JS sites served directly by nginx (no Docker container), see `references/static-site-deployment.md` for the complete pattern including home nginx `root` + `try_files`, file permissions, VPS HTTPS proxy, and certbot per-subdomain SSL.
|
||||
|
||||
## Step 10: Landing page
|
||||
|
||||
Create a simple HTML landing page at `grajmedia.duckdns.org` linking to all services:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name grajmedia.duckdns.org;
|
||||
|
||||
location = / {
|
||||
return 200 '<!DOCTYPE html>
|
||||
<html><head><title>grajmedia</title>
|
||||
<style>body{font-family:-apple-system,sans-serif;max-width:600px;margin:60px auto;padding:20px;background:#111;color:#eee}
|
||||
a{color:#4fc3f7;text-decoration:none;display:block;padding:8px 0;font-size:18px}
|
||||
a:hover{color:#81d4fa}h1{font-size:28px;margin-bottom:24px}</style></head>
|
||||
<body><h1>grajmedia services</h1>
|
||||
<a href=https://immich-grajmedia.duckdns.org>Immich — photos</a>
|
||||
<a href=https://paperless-grajmedia.duckdns.org>Paperless — documents</a>
|
||||
<a href=https://ha-grajmedia.duckdns.org>Home Assistant</a>
|
||||
<a href=https://mealie-grajmedia.duckdns.org>Mealie — recipes</a>
|
||||
<a href=https://audiobookshelf-grajmedia.duckdns.org>Audiobookshelf</a>
|
||||
<a href=https://shopproquote-grajmedia.duckdns.org>ShopProQuote</a>
|
||||
</body></html>';
|
||||
add_header Content-Type text/html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Updating the landing page
|
||||
|
||||
The page is rendered via `return 200 '...'` in nginx. To add or remove services, edit `/etc/nginx/sites-available/grajmedia` and reload nginx.
|
||||
|
||||
## Post-migration: clean up home server
|
||||
|
||||
After verifying all services are accessible through the VPS:
|
||||
|
||||
1. **Remove port forwards** on the ASUS router — no more ports 443, 3443-3450 exposed
|
||||
2. **Optionally remove SSL from home nginx** — services are now accessed via VPS SSL, so home nginx can drop to HTTP-only (simpler config, faster local access)
|
||||
3. **Keep Tailscale running** — it's the backbone of the tunnel
|
||||
|
||||
## Security hardening the VPS
|
||||
|
||||
The VPS is the public face of all services. Apply these hardening steps to any service exposed through the VPS.
|
||||
|
||||
### Hide nginx version
|
||||
|
||||
```bash
|
||||
sudo sed -i 's/server_tokens build;/server_tokens off;/' /etc/nginx/nginx.conf
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### Add rate limiting
|
||||
|
||||
Add a zone to the http block in `/etc/nginx/nginx.conf`:
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=<zone_name>:10m rate=5r/m;
|
||||
```
|
||||
|
||||
Apply per-service in each server block:
|
||||
```nginx
|
||||
limit_req zone=<zone_name> burst=5 nodelay;
|
||||
```
|
||||
|
||||
### Security headers
|
||||
|
||||
Add to the location block of each VPS nginx server block. Headers at the server block level may be dropped when a location block with `proxy_pass` exists — put them inside `location`:
|
||||
```nginx
|
||||
location / {
|
||||
# ... proxy_pass, proxy_set_header directives ...
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), midi=(), sync-xhr=(), clipboard-read=(), clipboard-write=()" always;
|
||||
}
|
||||
```
|
||||
|
||||
### Install fail2ban for nginx HTTP auth
|
||||
|
||||
The default nginx-http-auth filter watches **error.log** (not access.log):
|
||||
```bash
|
||||
sudo apt-get install -y fail2ban
|
||||
sudo tee /etc/fail2ban/jail.d/nginx-http-auth.conf << 'EOF'
|
||||
[nginx-http-auth]
|
||||
enabled = true
|
||||
port = http,https
|
||||
filter = nginx-http-auth
|
||||
logpath = /var/log/nginx/error.log
|
||||
maxretry = 5
|
||||
bantime = 3600
|
||||
findtime = 300
|
||||
EOF
|
||||
sudo systemctl restart fail2ban
|
||||
```
|
||||
|
||||
Verify: `sudo fail2ban-client status nginx-http-auth`
|
||||
|
||||
### Pitfall: `sites-enabled` is a copy, not a symlink
|
||||
|
||||
When a VPS nginx site config is created as a **separate file** in `sites-enabled/` (rather than a symlink to `sites-available/`), future edits to `sites-available/` are silently ignored. nginx continues reading the stale copy.
|
||||
|
||||
```bash
|
||||
# Check
|
||||
ls -la /etc/nginx/sites-enabled/<service>
|
||||
# "-rw-r--r--" = copy (bad), "lrwxrwxrwx" = symlink (good)
|
||||
|
||||
# Fix
|
||||
sudo rm /etc/nginx/sites-enabled/<service>
|
||||
sudo ln -s /etc/nginx/sites-available/<service> /etc/nginx/sites-enabled/<service>
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
This affects the `sudo ln -sf` command in the enable step — if the site was originally created by a process that writes files directly (e.g., certbot, manual copy, migration from another host), it may be a standalone file.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Subdomain server_name mismatch (custom domain)**: When adding a new subdomain on the VPS (SSL cert + nginx server block), you MUST also add that subdomain to the HOME server nginx server_name for the target service. Without this, the home nginx routes traffic to the default server block (404 or wrong content). Fix: update the home nginx server_name to include <subdomain>.graj-media.com, then reload nginx. Verify with `curl -sI -H 'Host: <subdomain>.graj-media.com' http://localhost:<home_port>`.
|
||||
- **VPS Host header uses bare apex domain instead of $host or subdomain**: When hand-writing a VPS nginx config for a custom-domain subdomain, it's easy to write `proxy_set_header Host graj-media.com;` (just the root domain) instead of `proxy_set_header Host $host;` (forwards the original subdomain verbatim). This drops the subdomain prefix, so the home nginx never matches the server_name. **Symptoms**: the request hits the home nginx default server block (often a 404 or wrong service) instead of the intended one. **Fix**: use `proxy_set_header Host $host;` on the VPS (for custom domain with per-subdomain certs) or hardcode the base DuckDNS domain (for the DuckDNS pattern). **Diagnostic**: SSH into the home server and run `curl -sI -H 'Host: mealie.graj-media.com' http://localhost:9925/` — if the service responds correctly locally, the problem is the Host header sent by the VPS. <br><br>**Three-way decision for Host header on VPS**:<br>1. `proxy_set_header Host $host;` — use when the VPS has its own per-subdomain cert AND the home server's server_name includes that subdomain. This preserves the original hostname through the chain.<br>2. `proxy_set_header Host grajmedia.duckdns.org;` — use when the VPS uses a DuckDNS subdomain and the home server's server_name is only the base DuckDNS domain. The home nginx doesn't know about the hyphenated DuckDNS subdomain, so you hardcode the one name it recognizes.<br>3. `proxy_set_header Host <bare-apex>;` — NEVER use this. The bare apex (e.g., `graj-media.com`) matches no server block on the home server. If you see this, it's almost certainly a copy-paste or manual-entry error.
|
||||
- **DuckDNS auto-detects source IP**: When the DuckDNS update script runs on the home server with `&ip=` (empty), DuckDNS uses the request's source IP — which is the HOME IP, not the VPS IP. For VPS reverse proxy setups, you MUST hardcode the VPS IP in the update URL: `&ip=51.81.84.34`. Otherwise the domain resolves to the home IP and bypasses the VPS entirely. Fix the cron script to always send the VPS IP.
|
||||
- **DuckDNS doesn't support dotted subdomains**: `immich.grajmedia.duckdns.org` won't resolve. Use hyphen format: `immich-grajmedia.duckdns.org`.
|
||||
- **DuckDNS auto-detects wrong IP**: The update script's `&ip=` parameter auto-detects the request's source IP. If the script runs on the home server but the domain should point to the VPS, DuckDNS sets the record to the home IP instead. **Fix:** always hardcode the VPS IP: `&ip=<VPS_IP>`. Script is at `/opt/duckdns/update.sh`. Verify with `dig +short <domain> @8.8.8.8`.
|
||||
- **/etc/hosts DNS override**: A local `/etc/hosts` entry like `127.0.0.1 grajmedia.duckdns.org` takes precedence over DNS. Useful for local access (avoids hairpin NAT), but causes confusion — `resolvectl query` shows "Data from: synthetic" when hosts file is active.
|
||||
- **certbot mangles configs on domain switch**: When migrating from DuckDNS to a new domain, certbot has already modified the nginx config files to add SSL directives AND secondary server blocks for HTTP→HTTPS redirect. Simply changing `server_name` values leaves broken configs (`listen` directives outside server blocks, missing certs). **The fix**: `sudo rm /etc/nginx/sites-enabled/*`, rewrite all configs from scratch, then re-run certbot for the new domain.
|
||||
- **Bash heredoc expansion in SSH commands**: Writing nginx configs via `ssh host "cat > file << 'EOF' ... $host ..."` may still expand `$host`, `$http_upgrade`, etc. because the double-quoted SSH wrapper triggers local expansion before the remote shell sees the heredoc. **Fix**: use single-quoted SSH: `ssh host 'cat > file << EOF ... $host ...'` (unquoted heredoc delimiter, but single-quoted SSH wrapper — the variable won't exist remotely so it stays literal), or patch the empty values afterwards with sed: `sudo sed -i 's|proxy_set_header Host ;|proxy_set_header Host \$host;|'`.
|
||||
- **Home nginx is SSL-only**: When proxying from VPS to home server nginx, you must use HTTPS with `proxy_ssl_verify off` and proper SNI headers. Direct Docker ports (HTTP) are simpler when available.
|
||||
- **127.0.0.1 Docker bindings**: Services bound to `127.0.0.1` are unreachable from Tailscale. Either bind to `0.0.0.0` or proxy through home nginx SSL. See `references/docker-port-binding.md`.
|
||||
- **certbot NXDOMAIN**: If certbot fails with NXDOMAIN, the DNS entry doesn't exist yet. Add the subdomain on DuckDNS first, wait for propagation, retry.
|
||||
- **OVH cloud images use `ubuntu` user by default**, not `root`. SSH keys go in `/home/ubuntu/.ssh/`, not `/root/.ssh/`. After key setup, use `sudo` to copy to root.
|
||||
- **Vaultwarden deployment**: Complete worked example — Docker Compose, home nginx with WebSocket support, VPS nginx, SSL setup, post-deploy steps. See `references/vaultwarden.md`.\n- **Plex Media Server**: Host-networked Plex proxied through VPS — large file streaming, WebSocket, 401 auth flow. See `references/plex.md`.\n
|
||||
- **Permanent certificate migration for Docker services**: When a Docker service like Vaultwarden uses Let's Encrypt certs managed by the host nginx (not by the container), the container doesn't need its own cert volume. Just bind-mount the data directory and let nginx handle SSL.
|
||||
- **DuckDNS auto-IP picks the wrong server**: When running the DuckDNS update script on the home server, leaving the `&ip=` parameter empty makes DuckDNS auto-detect the request's source IP — which is the HOME IP, not the VPS. The domain then resolves to the home server directly, bypassing the VPS entirely. **Fix**: hardcode the VPS IP in the update URL: `&ip=51.81.84.34`. The cron script at `/opt/duckdns/update.sh` must use the explicit VPS IP, not auto-detect.
|
||||
- **Let's Encrypt rate limits**: Certbot has a 5-certificates-per-domain-per-week limit. Bundle all subdomains into one cert (comma-separated `-d` flags) rather than separate certs per service.
|
||||
- **nginx fails at boot with 'host not found in upstream'**: nginx resolves all upstream hostnames at startup. If DNS isn't ready yet (common at boot, especially with `resolv.conf` pointing to `127.0.0.53` and systemd-resolved still initializing), nginx refuses to start — and ONE bad upstream takes down ALL sites. Fix: use runtime DNS resolution with `resolver` + variable. See `references/nginx-runtime-dns.md` for the pattern and worked example.
|
||||
- **Corporate firewall NRD (Newly Registered Domain) blocking**: Domains registered less than 30–60 days ago are blocked by most corporate web filters (Cisco Umbrella, FortiGuard, etc.) under the "Newly Registered and Observed Domains" category. This blocks ALL subdomains (`*.graj-media.com`) — the server config is fine, the block happens at the corporate DNS/firewall layer. No server-side fix exists. Workarounds: (1) Use an older well-established domain (like a years-old DuckDNS subdomain) as a temporary alias pointing to the same VPS — the NRD filter won't flag it. (2) Access directly via Tailscale IP (`https://100.93.253.36`) from work machines with Tailscale installed. (3) Ask corporate IT for a domain whitelist exception. The block lifts automatically once the domain ages past the NRD window (typically 30 days post-registration). See `references/duckdns-temp-alias.md` for the full temp-alias setup.
|
||||
- **Host header mismatch → SPA redirect loop**: When the VPS `proxy_pass` sends a `Host` header that doesn't match the home server nginx `server_name`, the request lands on the wrong server block (or the default). In SPAs with auth (ShopProQuote, etc.), this manifests as an infinite redirect between `login.html` and `index.html` — the auth check fails because API calls hit a different nginx context, the cookie/localStorage domain doesn't match, or the default server block serves unexpected content. See `references/spa-redirect-loop.md` for the full diagnostic workflow, worked example, and three fix patterns (file rename + reference update, server alias, stub page cache-breaker). **Two fixes, use the one that fits: (A) Add the VPS domain as a server alias on the home nginx:** `server_name grajmedia.duckdns.org <vps-domain1> <vps-domain2>;` then `sudo nginx -t && sudo systemctl reload nginx`. This preserves the original Host header through the chain. **(B) Match the Host header to what home nginx expects:** `proxy_set_header Host grajmedia.duckdns.org;` in the VPS nginx config. Option A is preferred when you have multiple services — it keeps Host-based routing intact. **Always verify** the home nginx config's `server_name` and ensure the Host header reaching it resolves to the right server block. **Browser cache trap:** after all server-side fixes are applied, the user may STILL see the stale redirect because their browser cached the 302 from the old setup. Verify with `curl -skI https://domain/` — if the server returns 200 but the browser shows a redirect, the fix is a hard refresh (Ctrl+Shift+R) or clearing site data in DevTools. A stub page at the old redirect target with a `<meta http-equiv="refresh">` to the correct page + no-cache headers breaks the cached redirect without user intervention.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Docker Port Binding and Tailscale Reachability
|
||||
|
||||
When Docker containers bind to `127.0.0.1` (loopback), they are only reachable from the host machine. Tailscale traffic arrives on the `tailscale0` interface and cannot reach `127.0.0.1`-bound ports.
|
||||
|
||||
## Detection
|
||||
|
||||
From the VPS via Tailscale:
|
||||
|
||||
```bash
|
||||
# Port returns 000 (connection refused) — likely 127.0.0.1 binding
|
||||
curl -s -o /dev/null -w '%{http_code}' --connect-timeout 2 http://100.93.253.36:9925/
|
||||
# 000
|
||||
```
|
||||
|
||||
On the home server, verify the binding:
|
||||
|
||||
```bash
|
||||
docker inspect <container> --format '{{json .HostConfig.PortBindings}}' | python3 -m json.tool
|
||||
# {"9000/tcp": [{"HostIp": "127.0.0.1", "HostPort": "9925"}]}
|
||||
```
|
||||
|
||||
## Solutions
|
||||
|
||||
### Option A: Proxy through home nginx (preferred)
|
||||
|
||||
The home server's nginx already has SSL configs for these services on ports 3443-3450. From the VPS, proxy to the home nginx SSL endpoint with SNI:
|
||||
|
||||
```nginx
|
||||
proxy_pass https://100.93.253.36:3449; # Mealie via home nginx
|
||||
proxy_ssl_verify off;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_name grajmedia.duckdns.org;
|
||||
proxy_set_header Host grajmedia.duckdns.org;
|
||||
```
|
||||
|
||||
This works because the home nginx listens on `0.0.0.0:3449` and proxies to `127.0.0.1:9925` locally.
|
||||
|
||||
### Option B: Rebind container to 0.0.0.0
|
||||
|
||||
Stop the container, recreate with `0.0.0.0` binding:
|
||||
|
||||
```bash
|
||||
docker stop mealie
|
||||
docker rm mealie
|
||||
docker run -d --name mealie \
|
||||
-p 0.0.0.0:9925:9000 \
|
||||
... (rest of original options)
|
||||
```
|
||||
|
||||
**Downside**: the service becomes accessible on the LAN without SSL. Only do this for services that don't need the home nginx SSL layer.
|
||||
|
||||
### Option C: SSH tunnel
|
||||
|
||||
As a last resort, create an SSH tunnel from VPS to home server:
|
||||
|
||||
```bash
|
||||
ssh -L 9925:127.0.0.1:9925 -N -f 100.93.253.36
|
||||
```
|
||||
|
||||
Then nginx on the VPS proxies to `http://127.0.0.1:9925`.
|
||||
|
||||
**Downside**: fragile, needs SSH keepalive, adds latency, requires SSH key setup.
|
||||
|
||||
## Services by binding type (rayserver)
|
||||
|
||||
| Service | Docker port | Binding | VPS approach |
|
||||
|---------|------------|---------|-------------|
|
||||
| Immich | 2283 | 0.0.0.0 | Direct HTTP |
|
||||
| Paperless | 8010 | 0.0.0.0 | Direct HTTP |
|
||||
| Home Assistant | 8123 | 0.0.0.0 (host net) | Direct HTTP |
|
||||
| Mealie | 9925 | 127.0.0.1 | Home nginx HTTPS proxy |
|
||||
| Audiobookshelf | 13378 | 0.0.0.0 | Direct HTTP |
|
||||
| PocketBase | 8091 | 127.0.0.1 | Home nginx HTTPS proxy |
|
||||
@@ -0,0 +1,78 @@
|
||||
# DuckDNS Temporary Domain Alias
|
||||
|
||||
Use when a domain is NRD-blocked by corporate firewalls or a new domain needs immediate accessibility. The DuckDNS domain (usually years old) bypasses NRD filters.
|
||||
|
||||
## When needed
|
||||
|
||||
- Domain registered less than 30 days ago → corporate networks block it
|
||||
- Need a quick workaround while the domain ages past the NRD window
|
||||
- Already have a DuckDNS domain set up and pointing to the VPS
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Update DuckDNS to point to VPS
|
||||
|
||||
```bash
|
||||
# If DuckDNS points to 127.0.0.1 or is stale:
|
||||
curl -s "https://www.duckdns.org/update?domains=<duckdns-name>&token=<token>&ip=<vps-ip>"
|
||||
```
|
||||
|
||||
### 2. Set up auto-updater
|
||||
|
||||
DuckDNS requires periodic updates to keep the IP current. Create a simple cron:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.hermes/scripts
|
||||
cat > ~/.hermes/scripts/duckdns-update.sh << 'SCRIPT'
|
||||
#!/bin/bash
|
||||
# IMPORTANT: &ip=<vps-ip> must be explicit. Leaving &ip= empty causes DuckDNS to
|
||||
# auto-detect the requesting server's public IP (the HOME server, not the VPS),
|
||||
# which breaks DNS for all services.
|
||||
echo "$(date): $(curl -s 'https://www.duckdns.org/update?domains=<name>&token=<token>&ip=<vps-ip>&verbose=true')" >> ~/duckdns/update.log
|
||||
SCRIPT
|
||||
chmod +x ~/.hermes/scripts/duckdns-update.sh
|
||||
```
|
||||
|
||||
Then schedule via `cronjob` tool: `no_agent=true`, `script=duckdns-update.sh`, `schedule=every 5m`, `deliver=local`.
|
||||
|
||||
### 3. Add VPS nginx server block for the new domain
|
||||
|
||||
For a service that sits behind the home server's nginx SSL (like ShopProQuote on port 443):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name <duckdns-domain>;
|
||||
|
||||
location / {
|
||||
proxy_pass https://<home-tailscale-ip>:<port>;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host <duckdns-domain>; # MUST match home nginx server_name
|
||||
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_ssl_verify off;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
listen 80;
|
||||
}
|
||||
```
|
||||
|
||||
Link it: `ln -sf /etc/nginx/sites-available/<name> /etc/nginx/sites-enabled/`
|
||||
|
||||
### 4. Provision SSL
|
||||
|
||||
```bash
|
||||
certbot --nginx -d <duckdns-domain> --non-interactive --agree-tos --email <email>
|
||||
```
|
||||
|
||||
### 5. Critical: Host header must match home nginx server_name
|
||||
|
||||
The home server nginx has a specific `server_name` in its config. If the VPS proxy sends a different `Host` header, the request hits the wrong server block (or the default), which in SPAs causes redirect loops between login and index pages. Always verify the home nginx config's `server_name` and match it exactly in `proxy_set_header Host`.
|
||||
|
||||
## Pitfall: localStorage auth tokens
|
||||
|
||||
PocketBase and Firebase store auth tokens in `localStorage`, which is domain-scoped. Users must re-authenticate on the new domain — their old domain's session won't carry over.
|
||||
@@ -0,0 +1,22 @@
|
||||
# DuckDNS VPS Routing Fix
|
||||
|
||||
When routing `grajmedia.duckdns.org` through the VPS reverse proxy, the DuckDNS
|
||||
update script MUST target the VPS IP, not auto-detect the home server IP.
|
||||
|
||||
## Problem
|
||||
|
||||
The DuckDNS update script at `/opt/duckdns/update.sh` used `&ip=` (empty, auto-detect),
|
||||
so DuckDNS recorded the home server's public IP (`162.81.173.34`) instead of the
|
||||
VPS IP (`51.81.84.34`). Traffic was bypassing the VPS entirely.
|
||||
|
||||
## Fix
|
||||
|
||||
Hard-code the VPS IP in the update script:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
echo url="https://www.duckdns.org/update?domains=grajmedia&token=TOKEN&ip=51.81.84.34" | curl -k -s -o /opt/duckdns/duck.log -K -
|
||||
```
|
||||
|
||||
Also check `/etc/hosts` for local overrides like `127.0.0.1 grajmedia.duckdns.org`
|
||||
(kept for local access to avoid hairpin NAT).
|
||||
@@ -0,0 +1,95 @@
|
||||
# Nginx Runtime DNS Resolution
|
||||
|
||||
## Problem
|
||||
|
||||
nginx resolves all `proxy_pass` hostnames at **startup time**. If DNS isn't available when nginx starts (common at boot, especially with systemd-resolved still initializing), it fails with:
|
||||
|
||||
```
|
||||
host not found in upstream "api.deepseek.com" in /etc/nginx/sites-enabled/shopproquote:7
|
||||
```
|
||||
|
||||
**One bad upstream takes down every site.** All services become unreachable until someone manually restarts nginx.
|
||||
|
||||
## Solution: Runtime DNS via variable
|
||||
|
||||
When `proxy_pass` uses a **variable**, nginx resolves at **request time** instead of startup. Three directives needed:
|
||||
|
||||
```nginx
|
||||
location /deepseek/ {
|
||||
resolver 127.0.0.53 valid=30s; # DNS server + cache TTL
|
||||
set $deepseek_upstream api.deepseek.com; # variable holds hostname
|
||||
proxy_pass https://$deepseek_upstream/; # resolves at request time
|
||||
# ... rest of location block unchanged
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
|
||||
- `resolver` — use the local DNS stub (`127.0.0.53` on systemd-resolved systems, `8.8.8.8` as fallback). `valid=30s` caches resolved IPs for 30 seconds.
|
||||
- `set $upstream_name` — the variable name is arbitrary but must be unique per location.
|
||||
- `proxy_pass https://$upstream_name/` — the trailing `/` is critical (strips the location prefix from the proxied URI, same behavior as static `proxy_pass`).
|
||||
|
||||
## Worked example: ShopProQuote DeepSeek proxy
|
||||
|
||||
**Before** (static — fails at boot if DNS down):
|
||||
|
||||
```nginx
|
||||
location /deepseek/ {
|
||||
proxy_pass https://api.deepseek.com/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host api.deepseek.com;
|
||||
proxy_set_header Authorization "Bearer sk-f73...b026";
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_protocols TLSv1.2 TLSv1.3;
|
||||
proxy_read_timeout 120;
|
||||
}
|
||||
```
|
||||
|
||||
**After** (runtime DNS — safe at boot):
|
||||
|
||||
```nginx
|
||||
location /deepseek/ {
|
||||
resolver 127.0.0.53 valid=30s;
|
||||
set $deepseek_upstream api.deepseek.com;
|
||||
proxy_pass https://$deepseek_upstream/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host api.deepseek.com;
|
||||
proxy_set_header Authorization "Bearer sk-f73...b026";
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_protocols TLSv1.2 TLSv1.3;
|
||||
proxy_read_timeout 120;
|
||||
}
|
||||
```
|
||||
|
||||
Test: `nginx -t && systemctl reload nginx`
|
||||
|
||||
## When to apply
|
||||
|
||||
This pattern is needed for **any** `proxy_pass` to an external hostname (not a Tailscale IP or localhost). External hostnames that could be unresolvable at boot:
|
||||
|
||||
- API endpoints (`api.deepseek.com`, `api.openai.com`)
|
||||
- CDN origins
|
||||
- Third-party service backends
|
||||
|
||||
**NOT needed** for:
|
||||
- Tailscale IPs (`http://100.93.253.36:11434/`) — always resolvable, no DNS involved
|
||||
- `localhost` / `127.0.0.1`
|
||||
- IP literals of any kind
|
||||
|
||||
## Diagnosing at runtime
|
||||
|
||||
```bash
|
||||
# Is nginx running?
|
||||
systemctl status nginx
|
||||
|
||||
# What error killed it?
|
||||
journalctl -u nginx --since "5 min ago" --no-pager | grep -i "host not found"
|
||||
|
||||
# Does DNS work now?
|
||||
nslookup api.deepseek.com
|
||||
|
||||
# If DNS works now but nginx is down, just restart:
|
||||
systemctl restart nginx
|
||||
```
|
||||
@@ -0,0 +1,87 @@
|
||||
# Plex Deployment (VPS Reverse Proxy)
|
||||
|
||||
Plex Media Server deployed with host networking on the home server, exposed through VPS reverse proxy.
|
||||
|
||||
## Context
|
||||
|
||||
Plex uses host networking for device discovery (DLNA, GDM). It listens on port 32400 directly on the host. No Docker port mapping needed — just proxy through home nginx → VPS.
|
||||
|
||||
## Home Nginx Config
|
||||
|
||||
Key differences from standard service config:
|
||||
- `client_max_body_size 0` — no upload limit for large media
|
||||
- `proxy_request_buffering off` — stream requests without buffering (needed for media playback)
|
||||
- Long read timeout for streaming
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 3447 ssl;
|
||||
server_name grajmedia.duckdns.org plex.graj-media.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/grajmedia.duckdns.org/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/grajmedia.duckdns.org/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
client_max_body_size 0;
|
||||
proxy_request_buffering off;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:32400;
|
||||
proxy_http_version 1.1;
|
||||
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_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## VPS Nginx Config
|
||||
|
||||
Standard HTTPS proxy to home:3447. Same as other services but with matching `client_max_body_size 0` and `proxy_request_buffering off`.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name plex.graj-media.com;
|
||||
|
||||
client_max_body_size 0;
|
||||
proxy_request_buffering off;
|
||||
|
||||
location / {
|
||||
proxy_pass https://100.93.253.36:3447;
|
||||
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_ssl_verify off;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/graj-media.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/graj-media.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
```
|
||||
|
||||
## SSL
|
||||
|
||||
```bash
|
||||
certbot --nginx -d plex.graj-media.com --non-interactive --agree-tos --email admin@grajmedia.duckdns.org
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Plex sign-in returns 401** — normal. Plex requires browser-based auth via their sign-in flow. `curl` will always get 401.
|
||||
- **No container restart needed** — Plex runs as a systemd service with host networking, not a Docker container. The proxy configs don't touch Plex itself.
|
||||
- **Port conflict check** — before picking the home nginx SSL port (3447), verify it's unused: `sudo ss -tlnp | grep :3447`
|
||||
@@ -0,0 +1,167 @@
|
||||
# SPA Auth Redirect Loop — Diagnostic Workflow
|
||||
|
||||
## Symptoms
|
||||
|
||||
User visits `https://domain/` and the page flickers/refreshes repeatedly, alternating between two URLs (e.g., `index.html` and `login.html`). The browser URL bar visibly changes back and forth.
|
||||
|
||||
## Root Causes (check in order)
|
||||
|
||||
1. **Host header mismatch** — VPS `proxy_set_header` sends a Host the home nginx doesn't recognize. Request hits the default server block instead of the correct one. PocketBase/Firebase API calls fail or hit wrong context → auth state flips → redirect loop.
|
||||
|
||||
2. **Stale auth token in localStorage** — PocketBase JS SDK reads an expired token from `localStorage`, initially considers it valid (JWT not yet expired), but the first API call returns 401 → authStore clears → redirect fires. On the next page load, the token is read from localStorage again → cycle repeats.
|
||||
|
||||
3. **Bounce-back doesn't clear the stale token** — Even after the architecture is fixed (one-way redirects), the protected pages' `onAuthStateChanged(null)` handlers redirect to the login page WITHOUT clearing the stale `pocketbase_auth` from localStorage. The login page's inline check (`if(localStorage.getItem('pocketbase_auth'))`) fires again → redirects back → infinite loop. **Fix**: add `localStorage.removeItem('pocketbase_auth')` immediately before `window.location.href = 'index.html'` in every protected page's auth-failure path. See `references/auth-redirect-loop.md` in the `shop-pro-quote` skill for the full file list.
|
||||
|
||||
4. **Browser-cached 302 redirect** — after fixing the server, the user still sees the old loop because their browser cached the 302 redirect from before the fix. `curl -skI` returns 200, but the browser shows a redirect.
|
||||
|
||||
## Diagnostic Steps
|
||||
|
||||
### 0. FULL-TEXT GREP FIRST (do not skip this)
|
||||
|
||||
**Before looking at individual files or nav links, grep EVERY JS file for all redirect sources at once.** The redirect can be buried anywhere — an auth listener, a guard function, a keyboard shortcut handler, an inline script. Nav links are the most visible but often NOT the root cause.
|
||||
|
||||
```bash
|
||||
# Find ALL redirect sources in one sweep
|
||||
grep -rn "window\.location\|location\.replace\|location\.href\|meta.*refresh" --include="*.js" --include="*.html" .
|
||||
```
|
||||
|
||||
Missing a hidden `onAuthStateChanged` redirect is the #1 diagnostic failure in SPA redirect loops. The fix may look deceptively simple (rename files, update nav links) but the real loop lives in auth listeners that fire on state changes.
|
||||
|
||||
### 1. Check what the server actually returns
|
||||
|
||||
```bash
|
||||
curl -skI https://domain/
|
||||
# HTTP/1.1 200 OK → server is fine, problem is client-side
|
||||
# HTTP/1.1 302 + Location: login.html → server still has old redirect
|
||||
```
|
||||
|
||||
### 2. Check if the target file exists
|
||||
|
||||
```bash
|
||||
curl -skI https://domain/login.html
|
||||
# 404 → file was deleted, redirect is cached
|
||||
# 200 → file still exists, redirect is real
|
||||
```
|
||||
|
||||
### 3. Check VPS ↔ Home Host header alignment
|
||||
|
||||
```bash
|
||||
# On VPS: what Host header goes to home?
|
||||
grep "proxy_set_header Host" /etc/nginx/sites-enabled/<service>
|
||||
|
||||
# On home: what server_name does nginx expect?
|
||||
grep "server_name" /etc/nginx/sites-enabled/* /etc/nginx/sites-available/*
|
||||
```
|
||||
|
||||
### 4. Check for stale PocketBase auth token
|
||||
|
||||
Open browser DevTools → Application → Local Storage → look for `pocketbase_auth` key. If present, the SDK will try to use it on page load. If the token is expired server-side but not client-side, this creates the loop.
|
||||
|
||||
## Fix Patterns
|
||||
|
||||
### Pattern A: Server alias (preferred)
|
||||
|
||||
Add the VPS domain as a server_name alias on the home nginx config:
|
||||
|
||||
```bash
|
||||
sudo sed -i 's/server_name grajmedia.duckdns.org;/server_name grajmedia.duckdns.org <vps-domain>;/' /etc/nginx/sites-enabled/<config>
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Allows `Host: <vps-domain>` to reach the correct server block without changing the VPS config.
|
||||
|
||||
### Pattern B: Fix Host header on VPS
|
||||
|
||||
```nginx
|
||||
proxy_set_header Host grajmedia.duckdns.org;
|
||||
```
|
||||
|
||||
Matches what the home nginx expects. Only affects one service.
|
||||
|
||||
### Pattern C: File rename — swap index and login
|
||||
|
||||
When the root `index.html` is the dashboard (auth-guarded) and `login.html` is separate, visiting `/` triggers: load dashboard → no auth → redirect to login.html → has auth → redirect to index.html → loop.
|
||||
|
||||
**Fix**: Make `index.html` the login page, rename dashboard to `dashboard.html`, update ALL references:
|
||||
|
||||
```bash
|
||||
# 1. Backup
|
||||
cp login.html _login.html.bak && cp index.html _index.html.bak
|
||||
|
||||
# 2. Swap
|
||||
cp login.html index.html && cp _index.html.bak dashboard.html && rm login.html
|
||||
|
||||
# 3. Update JS references (grep first to find them all)
|
||||
grep -rn "login\.html\|index\.html" --include="*.js" --include="*.html" .
|
||||
|
||||
# 4. Bulk-fix auth redirects: login.html → index.html
|
||||
grep -rl "'login\.html'" --include="*.js" . | xargs sed -i "s/'login\.html'/'index.html'/g"
|
||||
|
||||
# 5. Bulk-fix nav links: index.html → dashboard.html
|
||||
grep -rl 'href="index\.html"' --include="*.html" . | xargs sed -i 's|href="index\.html"|href="dashboard.html"|g'
|
||||
|
||||
# 6. Fix login-redirect targets: index.html? → dashboard.html?
|
||||
sed -i "s/'index\.html?/'dashboard.html?/g" login.js # after-login redirect
|
||||
```
|
||||
|
||||
Full file list from a real fix (13 files, 28 references):
|
||||
- `dashboard.js`, `login.js`, `main.js`, `appointments.js`, `repair-orders.js`, `customers.js`
|
||||
- `shared/header-functionality.js`
|
||||
- `dashboard.html`, `appointments.html`, `repair-orders.html`, `customers.html`
|
||||
- `index.html` (the new login page — inline redirect scripts)
|
||||
|
||||
### Pattern D: Stub page cache-breaker
|
||||
|
||||
When the browser has a cached 302 redirect to a now-deleted file, put a minimal stub back that redirects to the correct page with no-cache headers:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<meta http-equiv="refresh" content="0; url=index.html">
|
||||
<title>Redirecting…</title>
|
||||
</head>
|
||||
<body><p>Redirecting… <a href="index.html">click here</a> if not redirected.</p></body>
|
||||
</html>
|
||||
```
|
||||
|
||||
This breaks the cached redirect: the browser loads the stub, which instantly takes them to the correct page. After a few visits the cache clears naturally.
|
||||
|
||||
### Pattern E: Remove auto-redirect from login page's onAuthStateChanged
|
||||
|
||||
When BOTH the login page and the dashboard have `onAuthStateChanged` listeners that redirect to each other, any auth-state instability creates a loop:
|
||||
|
||||
```
|
||||
dashboard.js: onAuthStateChanged(null) → redirect to login.html
|
||||
login.js: onAuthStateChanged(user) → redirect to index.html
|
||||
→ LOOP
|
||||
```
|
||||
|
||||
**Fix**: Patch the login page so `onAuthStateChanged` only manipulates UI (show/hide elements), never redirects. Only explicit login/signup button click handlers should redirect. Use an inline `<script>` at the top of the login page body to handle the "already logged in" redirect instead:
|
||||
|
||||
```html
|
||||
<script>if(localStorage.getItem('pocketbase_auth')){location.replace('dashboard.html?cb='+Date.now())}</script>
|
||||
```
|
||||
|
||||
This localStorage check fires before any modules load, has no auth-state dependency, and never causes a loop. It delegates the "already logged in" redirect to a simple token presence check, while login.js's `onAuthStateChanged` becomes UI-only.
|
||||
|
||||
## Worked Example: ShopProQuote
|
||||
|
||||
**Domain**: `grajmedia.duckdns.org` (VPS proxied to home:443)
|
||||
**Backend**: PocketBase at `127.0.0.1:8091`
|
||||
**Problem**: Infinite redirect between `index.html` (dashboard) and `login.html`, later between `login.html` → `dashboard.html` → `index.html`
|
||||
|
||||
**Root causes found (all three were active simultaneously)**:
|
||||
1. `shopproquote.graj-media.com` VPS config sent `Host: graj-media.com` but home nginx only had `server_name grajmedia.duckdns.org` → Host header mismatch → request landed on wrong server block
|
||||
2. `login.js`'s `onAuthStateChanged` auto-redirected to `dashboard.html` on auth state — coupled with dashboard.js's redirect back to `index.html` on null, any PocketBase auth instability created a loop. Also fired on `onAuthStateChanged` registration which could race with the initial state.
|
||||
3. After all server-side fixes, browser had cached 302 redirects from the old setup
|
||||
|
||||
**Fix applied (order matters — do all three)**:
|
||||
1. File swap (Pattern C): `index.html` → login page, `dashboard.html` → dashboard, 28 references across 13 files updated
|
||||
2. Patch `login.js` (Pattern E): `onAuthStateChanged` → UI-only (shows/hides form vs loading), no redirect. Only explicit login/signup + inline localStorage check redirect.
|
||||
3. Server alias (Pattern A): added `graj-media.com shopproquote.graj-media.com` to home shopproquote nginx `server_name`
|
||||
4. Stub page (Pattern D): created minimal `login.html` with no-cache redirect to `index.html` to break cached browser redirects, then deleted after cache cleared
|
||||
@@ -0,0 +1,119 @@
|
||||
# Static Site Deployment via VPS Reverse Proxy
|
||||
|
||||
Deploying a static HTML/CSS/JS website through the VPS reverse proxy infrastructure.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Internet → airrepairteam.graj-media.com (VPS nginx, SSL)
|
||||
↳ Tailscale → 100.93.253.36:3451 (home nginx, SSL duckdns)
|
||||
↳ root /mnt/seagate8tb/Websites/AirRepairTeam
|
||||
```
|
||||
|
||||
## Step 1: Home server nginx config
|
||||
|
||||
Static sites use `root` + `try_files`, not `proxy_pass`. Create in `/etc/nginx/sites-enabled/<name>`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen <PORT> ssl;
|
||||
server_name grajmedia.duckdns.org;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/grajmedia.duckdns.org/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/grajmedia.duckdns.org/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
root /mnt/seagate8tb/Websites/<ProjectName>;
|
||||
index index.html;
|
||||
|
||||
# Clean URLs — /hvac serves /hvac/index.html
|
||||
location / {
|
||||
try_files $uri $uri/ $uri/index.html =404;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Port selection: use the next available in the 344x/345x range. Check existing ports:
|
||||
```bash
|
||||
grep -r "listen" /etc/nginx/sites-enabled/ | grep -oP '\d{4}' | sort -n
|
||||
```
|
||||
|
||||
## Step 2: File permissions
|
||||
|
||||
nginx workers run as uid 911. Static files must be world-readable:
|
||||
```bash
|
||||
chmod -R a+rX /mnt/seagate8tb/Websites/<ProjectName>
|
||||
```
|
||||
|
||||
## Step 3: Test and reload
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo nginx -s reload
|
||||
```
|
||||
|
||||
Verify locally:
|
||||
```bash
|
||||
curl -sk -o /dev/null -w "%{http_code}" https://localhost:<PORT>/
|
||||
curl -sk -o /dev/null -w "%{http_code}" https://localhost:<PORT>/hvac/
|
||||
```
|
||||
|
||||
## Step 4: VPS nginx config
|
||||
|
||||
Create `/etc/nginx/sites-enabled/<name>` on the VPS. Start with HTTP-only (port 80) for certbot:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name <subdomain>.graj-media.com;
|
||||
client_max_body_size 50M;
|
||||
|
||||
location / {
|
||||
proxy_pass https://100.93.253.36:<PORT>;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host grajmedia.duckdns.org;
|
||||
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_ssl_verify off;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
listen 80;
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: SSL via certbot
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo nginx -s reload
|
||||
sudo certbot --nginx -d <subdomain>.graj-media.com --non-interactive --agree-tos -m admin@graj-media.com
|
||||
```
|
||||
|
||||
Certbot issues a separate cert per subdomain and adds SSL to the config. No need for `--expand`.
|
||||
|
||||
## Step 6: Verify
|
||||
|
||||
```bash
|
||||
curl -sk -o /dev/null -w '%{http_code}' https://<subdomain>.graj-media.com/
|
||||
curl -sk https://<subdomain>.graj-media.com/ | grep -o '<title>[^<]*</title>'
|
||||
```
|
||||
|
||||
## Step 7: Landing page
|
||||
|
||||
Add a link on graj-media.com by editing `/etc/nginx/sites-enabled/landing` and inserting before `</body>`:
|
||||
```html
|
||||
<a href=https://<subdomain>.graj-media.com>Service Name</a>
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **403 from home nginx**: File permissions too restrictive. Fix: `chmod -R a+rX /path/to/site`.
|
||||
- **Host header mismatch**: VPS must send `Host: grajmedia.duckdns.org` (home nginx server_name), not the VPS domain.
|
||||
- **Port already in use**: Check with `grep -r "listen" /etc/nginx/sites-enabled/`.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Vaultwarden Deployment (VPS Reverse Proxy)
|
||||
|
||||
Deploy Vaultwarden on the home server, exposed through VPS reverse proxy. Follows the standard VPS reverse proxy pattern.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```yaml
|
||||
# /opt/vaultwarden/docker-compose.yml
|
||||
services:
|
||||
vaultwarden:
|
||||
image: vaultwarden/server:latest
|
||||
container_name: vaultwarden
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /mnt/seagate8tb/docker/vaultwarden:/data
|
||||
ports:
|
||||
- "127.0.0.1:8812:80"
|
||||
environment:
|
||||
- DOMAIN=https://vault.graj-media.com
|
||||
- SIGNUPS_ALLOWED=true
|
||||
- WEBSOCKET_ENABLED=true
|
||||
- LOG_FILE=/data/vaultwarden.log
|
||||
- LOG_LEVEL=warn
|
||||
```
|
||||
|
||||
## Home Nginx Config
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 3446 ssl;
|
||||
server_name grajmedia.duckdns.org vault.graj-media.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/grajmedia.duckdns.org/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/grajmedia.duckdns.org/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
client_max_body_size 128M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8812;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
}
|
||||
|
||||
# WebSocket for live sync (browser extension)
|
||||
location /notifications/hub {
|
||||
proxy_pass http://127.0.0.1:8812;
|
||||
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;
|
||||
}
|
||||
|
||||
location /notifications/hub/negotiate {
|
||||
proxy_pass http://127.0.0.1:8812;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## VPS Nginx Config
|
||||
|
||||
Standard HTTPS proxy pattern. Home nginx expects `Host: vault.graj-media.com` — use `$host` in `proxy_set_header`.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
server_name vault.graj-media.com;
|
||||
|
||||
location / {
|
||||
proxy_pass https://100.93.253.36:3446;
|
||||
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_ssl_verify off;
|
||||
proxy_read_timeout 86400;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
listen 443 ssl;
|
||||
ssl_certificate /etc/letsencrypt/live/vault.graj-media.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/vault.graj-media.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
}
|
||||
```
|
||||
|
||||
## SSL
|
||||
|
||||
```bash
|
||||
certbot --nginx -d vault.graj-media.com --non-interactive --agree-tos --email admin@grajmedia.duckdns.org
|
||||
```
|
||||
|
||||
## Post-Deploy
|
||||
|
||||
1. Go to https://vault.graj-media.com and create your account (signups enabled initially)
|
||||
2. Install Bitwarden browser extension → Settings → Self-hosted → Server URL: `https://vault.graj-media.com`
|
||||
3. **After account creation, disable signups and set admin token:**
|
||||
```bash
|
||||
# Disable signups
|
||||
sudo docker stop vaultwarden
|
||||
sudo sed -i 's/SIGNUPS_ALLOWED=true/SIGNUPS_ALLOWED=false/' /opt/vaultwarden/docker-compose.yml
|
||||
|
||||
# Generate admin token (argon2id hash — do NOT use plain text)
|
||||
ADMIN_TOKEN=$(openssl rand -hex 32)
|
||||
echo "Admin token (save this): $ADMIN_TOKEN"
|
||||
HASHED=$(echo -n "$ADMIN_TOKEN" | argon2 "$(openssl rand -base64 32)" -e -id -k 65540 -t 3 -p 4 | grep 'Encoded:' | awk '{print $2}')
|
||||
echo "ADMIN_TOKEN=$HASHED" | sudo tee -a /opt/vaultwarden/.env
|
||||
|
||||
sudo docker compose -f /opt/vaultwarden/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
Admin panel: https://vault.graj-media.com/admin — enter the raw admin token (not the hash) to authenticate.
|
||||
4. **Bitwarden apps setup**: Desktop/Mobile → Settings gear ⚙️ → Self-hosted → Server URL: `https://vault.graj-media.com`. Browser extension → gear ⚙️ → Self-hosted environment → same URL.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **WebSocket required for live sync**: Without `/notifications/hub` proxy, the browser extension won't receive real-time updates (password changes, new items from other devices). Always include both the `Upgrade` headers AND the `/notifications/hub/negotiate` endpoint.
|
||||
- **Port conflict check**: Vaultwarden defaults to port 80 internally. Before deploying, check `docker ps` for port conflicts and pick an unused port for the host binding (used 8812 in this setup).
|
||||
Reference in New Issue
Block a user