initial commit

This commit is contained in:
ray
2026-07-12 10:17:17 -04:00
commit dab5a4ebc6
1424 changed files with 330463 additions and 0 deletions
@@ -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).