Phase 8: Production deployment (Debian 13, Nginx Proxy Manager)

Deployment artifacts for the two-server architecture from ARCHITECTURE.md
§9, grounded in verified Debian 13 (trixie) package facts (Python 3.13,
PostgreSQL 17, Node.js 20, redis-server 8.0, certbot 4.0, ufw --
confirmed rather than guessed) rather than a generic "modern Linux" guide:
deploy/systemd/chatapp.service, deploy/chatapp.env.example,
deploy/backup-postgres.sh, deploy/upgrade.sh, and DEPLOYMENT.md as the
actual numbered runbook.

Revised mid-implementation once the user clarified the app sits behind an
existing, separate Nginx Proxy Manager rather than local Nginx+certbot:
dropped the local Nginx config entirely, gunicorn now binds a TCP port
instead of a Unix socket, and app/main.py gained a static-file mount + SPA
fallback route so gunicorn alone serves the built frontend, /api, and /ws
on one port -- what lets NPM's simple one-upstream-per-domain mode work
with zero custom path routing. Path-traversal-guarded (full_path comes
straight from the URL) and cache-header-differentiated (far-future
immutable on Vite's content-hashed assets, no-cache on index.html/sw.js/
manifest so a deploy actually propagates instead of leaving clients on a
stale service worker) -- verified locally against a real gunicorn process
serving a real frontend build, not just eyeballed.

Two real gaps found and fixed alongside the docs, not just noted: gunicorn
wasn't a dependency anywhere despite being the whole app-server design, and
there was no WebSocket reconnect logic on the client -- a reverse proxy's
idle-connection timeout (NPM's or otherwise) would have silently killed a
quiet chat connection with nothing to recover it. Added exponential-backoff
reconnect to useChatSocket.ts, verified by hand (killed and restarted the
local dev backend mid-session, confirmed auto-reconnect and that a message
sends successfully afterward with no page reload).

Every command in DEPLOYMENT.md that could be verified locally, was: the
exact systemd ExecStart line run against local dev Postgres/Redis with
clean SIGTERM shutdown, the static-file serving behavior against a real
build, both shell scripts syntax-checked. What couldn't be verified from
this sandbox (actual Debian 13 hardware, Nginx Proxy Manager itself) is
flagged explicitly in the plan rather than claimed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 11:30:55 -06:00
co-authored by Claude Sonnet 5
parent 0ab23c44a7
commit 7f579bb508
10 changed files with 647 additions and 21 deletions
+41 -15
View File
@@ -7,6 +7,9 @@ interface UseChatSocketOptions {
onUnauthenticated: () => void
}
const RECONNECT_BASE_DELAY_MS = 1000
const RECONNECT_MAX_DELAY_MS = 30000
export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatSocketOptions) {
const socketRef = useRef<WebSocket | null>(null)
const [connected, setConnected] = useState(false)
@@ -16,28 +19,51 @@ export function useChatSocket({ roomId, onMessage, onUnauthenticated }: UseChatS
onUnauthenticatedRef.current = onUnauthenticated
useEffect(() => {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${protocol}://${location.host}/ws/chat`)
socketRef.current = ws
let stopped = false
let reconnectDelay = RECONNECT_BASE_DELAY_MS
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
ws.onopen = () => {
setConnected(true)
ws.send(JSON.stringify({ type: 'join', room_id: roomId }))
}
function connect() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${protocol}://${location.host}/ws/chat`)
socketRef.current = ws
ws.onmessage = (event) => {
onMessageRef.current(JSON.parse(event.data) as ServerEnvelope)
}
ws.onopen = () => {
reconnectDelay = RECONNECT_BASE_DELAY_MS
setConnected(true)
ws.send(JSON.stringify({ type: 'join', room_id: roomId }))
}
ws.onclose = (event) => {
setConnected(false)
if (event.code === 4401) {
onUnauthenticatedRef.current()
ws.onmessage = (event) => {
onMessageRef.current(JSON.parse(event.data) as ServerEnvelope)
}
ws.onclose = (event) => {
setConnected(false)
socketRef.current = null
if (event.code === 4401) {
onUnauthenticatedRef.current()
return
}
if (stopped) return
// Unexpected close -- a deploy restarting the app server, a brief
// network blip, or (absent any app-level ping/pong) an idle
// connection getting recycled by a reverse proxy in front of it.
// Retry with exponential backoff instead of leaving the chat
// silently dead until the user manually reloads.
reconnectTimer = setTimeout(connect, reconnectDelay)
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_DELAY_MS)
}
}
connect()
return () => {
ws.close()
stopped = true
if (reconnectTimer) clearTimeout(reconnectTimer)
socketRef.current?.close()
socketRef.current = null
}
}, [roomId])