Files
ds-chat/deploy/systemd/chatapp.service
T
ksmithandClaude Sonnet 5 7f579bb508 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>
2026-08-14 11:30:55 -06:00

55 lines
2.0 KiB
Desktop File

# /etc/systemd/system/chatapp.service
#
# Install: sudo cp deploy/systemd/chatapp.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now chatapp
#
# See ../../DEPLOYMENT.md for the full app-server setup this fits into.
# TLS termination and public-facing reverse proxying are handled by an
# external Nginx Proxy Manager instance, not anything on this box -- this
# unit just needs to be reachable on the TCP port below.
[Unit]
Description=KeepItTalking chat service app server
After=network.target
[Service]
# No Type= override -- defaults to "simple", which is correct here since
# gunicorn runs in the foreground (no --daemon flag below) and doesn't send
# systemd's sd_notify readiness protocol.
User=chatapp
Group=chatapp
WorkingDirectory=/srv/chatapp/backend
EnvironmentFile=/etc/chatapp/env
Environment=PYTHONUNBUFFERED=1
# 0.0.0.0 because Nginx Proxy Manager runs on a separate host -- the actual
# security boundary is the `ufw` rule in DEPLOYMENT.md restricting this
# port to NPM's IP specifically, not the bind address. If NPM reaches this
# box over a private network interface, bind to that private IP instead
# for defense in depth (belt-and-suspenders on top of the firewall rule).
ExecStart=/srv/chatapp/backend/.venv/bin/gunicorn app.main:app \
-k uvicorn.workers.UvicornWorker \
--workers 4 \
--bind 0.0.0.0:8000 \
--timeout 30
# alembic upgrade head deliberately does NOT run here -- with --workers 4,
# every restart would race multiple processes trying to migrate at once.
# It's an explicit step in deploy/upgrade.sh instead, run once before the
# restart that picks up the new code.
Restart=on-failure
RestartSec=2
# Baseline hardening -- not a full systemd sandboxing pass, just the
# well-understood safe defaults for a service that doesn't need to write
# anywhere outside its own working directory.
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.target