Private
Public Access
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:
+21
-2
@@ -1,4 +1,4 @@
|
||||
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7)
|
||||
# KeepItTalking backend (Phase 1 + 2 + 4 + 5 + 6 + 7 + 8)
|
||||
|
||||
FastAPI + SQLAlchemy 2.0 (async) + PostgreSQL + Redis. Implements auth, room
|
||||
CRUD (open and private), room roles (owner/admin/member) and invites, a
|
||||
@@ -107,7 +107,8 @@ DATABASE_URL=postgresql+asyncpg://chatapp:chatapp@localhost:5432/chatapp_test .v
|
||||
|
||||
```
|
||||
app/
|
||||
main.py create_app(), session middleware, router/WS mounting
|
||||
main.py create_app(), session middleware, router/WS mounting,
|
||||
serves frontend/dist if it exists (see below)
|
||||
config.py environment-driven settings (pydantic-settings)
|
||||
database.py async engine/session, get_db() dependency
|
||||
dependencies.py get_current_user (session cookie or Bearer token),
|
||||
@@ -128,6 +129,24 @@ alembic/ migrations
|
||||
tests/ pytest + httpx/TestClient tests
|
||||
```
|
||||
|
||||
## Production deployment (Phase 8)
|
||||
|
||||
See [`../DEPLOYMENT.md`](../DEPLOYMENT.md) for the full runbook. The one
|
||||
piece that lives in this backend's own code: `app/main.py` serves the built
|
||||
frontend directly (mounts `frontend/dist/assets` with far-future
|
||||
`Cache-Control` on Vite's content-hashed filenames, and a catch-all route
|
||||
that serves any other real file under `frontend/dist` or falls back to
|
||||
`index.html` for client-side routes like `/rooms/<id>` — `index.html`/
|
||||
`sw.js`/`manifest.webmanifest` always get `Cache-Control: no-cache` instead,
|
||||
since caching any of those is exactly how a client ends up stuck on a stale
|
||||
app version after a deploy) — but only if `frontend/dist` exists at
|
||||
startup. It never does in local dev (the Vite dev server handles the
|
||||
frontend there instead), so this is fully inert until someone actually runs
|
||||
`npm run build`. The point: one Gunicorn port ends up serving the frontend
|
||||
*and* `/api` *and* `/ws`, which is what lets a reverse proxy (Nginx Proxy
|
||||
Manager, in the deployment this was built for) forward a whole domain to a
|
||||
single upstream with no custom per-path routing.
|
||||
|
||||
## Admin portal (Phase 6)
|
||||
|
||||
Every `/api/admin/*` route (`app/routers/admin.py`) requires
|
||||
|
||||
+57
-1
@@ -1,9 +1,12 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import pathlib
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from redis.asyncio import Redis
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
@@ -14,6 +17,22 @@ from app.ws.chat import router as ws_router
|
||||
from app.ws.connection_manager import ConnectionManager
|
||||
from app.ws.presence import Presence
|
||||
|
||||
# backend/app/main.py -> backend/ -> repo root -- matches both the local
|
||||
# monorepo layout and the production layout (/srv/chatapp/backend,
|
||||
# /srv/chatapp/frontend/dist), which is the same relative shape.
|
||||
FRONTEND_DIST = pathlib.Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
|
||||
|
||||
|
||||
class ImmutableStaticFiles(StaticFiles):
|
||||
"""Vite fingerprints these filenames by content hash, so once served a
|
||||
given path never changes -- safe to cache aggressively and skip
|
||||
revalidation entirely, unlike index.html/sw.js below."""
|
||||
|
||||
async def get_response(self, path: str, scope) -> Response:
|
||||
response = await super().get_response(path, scope)
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
@@ -61,6 +80,43 @@ def create_app() -> FastAPI:
|
||||
app.include_router(webhooks.router)
|
||||
app.include_router(ws_router)
|
||||
|
||||
# Serves the built frontend so a single gunicorn port is enough for a
|
||||
# reverse proxy (e.g. Nginx Proxy Manager) to forward the whole domain
|
||||
# to -- no separate static-file host or per-path proxy routing needed
|
||||
# in front of it. Conditional on frontend/dist existing so local dev
|
||||
# (Vite's own dev server handles the frontend; frontend/dist is never
|
||||
# built there) is unaffected.
|
||||
if FRONTEND_DIST.is_dir():
|
||||
app.mount(
|
||||
"/assets", ImmutableStaticFiles(directory=FRONTEND_DIST / "assets"), name="frontend-assets"
|
||||
)
|
||||
|
||||
# Must always be revalidated -- caching any of these is exactly how
|
||||
# a client ends up stuck on a stale app version after a deploy.
|
||||
# Vite's hashed /assets/ files (ImmutableStaticFiles above) are the
|
||||
# opposite case on purpose: their filename changes when their
|
||||
# content does, so there's nothing to revalidate.
|
||||
NO_CACHE_FILES = {"index.html", "sw.js", "registerSW.js", "manifest.webmanifest"}
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def serve_frontend(full_path: str) -> FileResponse:
|
||||
if full_path.startswith("api/") or full_path.startswith("ws/"):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
# .resolve() + is_relative_to() guards against path traversal
|
||||
# (e.g. full_path="../../etc/passwd") -- full_path comes
|
||||
# straight from the URL, and plain Path./ doesn't stop ".."
|
||||
# segments from escaping FRONTEND_DIST on its own.
|
||||
candidate = (FRONTEND_DIST / full_path).resolve()
|
||||
headers = {"Cache-Control": "no-cache"} if full_path in NO_CACHE_FILES else None
|
||||
if full_path and candidate.is_relative_to(FRONTEND_DIST) and candidate.is_file():
|
||||
return FileResponse(candidate, headers=headers)
|
||||
|
||||
# Anything else is a client-side route (e.g. /rooms/<id>) --
|
||||
# fall back to the SPA shell, same as Nginx's `try_files $uri
|
||||
# /index.html` would have done.
|
||||
return FileResponse(FRONTEND_DIST / "index.html", headers={"Cache-Control": "no-cache"})
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"pywebpush>=2.0",
|
||||
"redis>=5.0",
|
||||
"httpx>=0.27",
|
||||
"gunicorn>=23.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
Reference in New Issue
Block a user