Backend
Deployment
A production setup has four kinds of process: the Next.js app, the Django API, Celery workers and one Celery beat. They share MongoDB (a replica set) and Redis. The settings of apps/backend-demo read everything from the environment and are a good starting point.
Environment
| Variable | Example | Used for |
|---|---|---|
| SECRET_KEY | 50+ random characters | Signing sessions, tokens and, by default, the 2FA encryption key. |
| DEBUG | 0 | Never 1 in production. |
| ALLOWED_HOSTS | api.plate.app,plate.app | Hosts Django answers. |
| CSRF_TRUSTED_ORIGINS | https://plate.app | Origins allowed to send unsafe requests. |
| MONGODB_URI | mongodb+srv://…/plate | The database (replica set, TLS, a dedicated user). |
| REDIS_URL | rediss://…:6379/0 | Cache, rate limits, Celery broker, realtime. |
| FRONTEND_URL | https://plate.app | Links in emails and redirects. |
| DEFAULT_FROM_EMAIL | Plate <hello@plate.app> | Sender. |
| SMTP_HOST / SMTP_PORT | smtp.example.com / 587 | Or an anymail API key. |
| ANTHROPIC_API_KEY | sk-ant-… | AI chat (or OPENAI_API_KEY). |
| GOOGLE_CLIENT_ID / _SECRET | … | OAuth, one pair per provider. |
| STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET | sk_live_… / whsec_… | Billing. |
| PLEATE_API_ORIGIN | http://api:8000 | On the Next.js side: where the /api rewrite goes. |
Processes
python manage.py migrate # on every release, before starting the new code
python manage.py collectstatic # admin assets (serve them with WhiteNoise or your CDN)
gunicorn -c gunicorn.conf.py myproject.wsgi # the API
celery -A myproject worker -l info -c 4 # as many as the load needs
celery -A myproject beat -l info # exactly onebind = "0.0.0.0:8000"
workers = 4
worker_class = "gthread"
threads = 32 # every open SSE stream holds one thread
timeout = 0 # streams are long lived; rely on the proxy's idle timeoutServer-Sent Events and ASGI
The notification stream and the chat answers are Server-Sent Events produced by synchronous generators. Under WSGI each open stream holds a worker thread for as long as it is open: a user with a tab open holds one thread for notifications, plus one while an answer streams. Size threads for your concurrent users, or run a separate pool for the streaming paths.
Whatever sits in front must not buffer the streams:
- The responses set
X-Accel-Buffering: no(nginx honors it) andCache-Control: no-cache. - The stream sends a comment every
HEARTBEAT_SECONDS(20) so idle timeouts of proxies and load balancers do not cut it; keep their idle timeout above that. - With several API processes use the Redis broker, or a notification published in one process never reaches a stream held by another.
Redis
REDIS_URL = env("REDIS_URL")
globals().update(redis(REDIS_URL)) # cache + Celery broker
PLEATE["REDIS_URL"] = REDIS_URL
PLEATE["REALTIME"] = {"BACKEND": "pleate_backend.core.realtime.RedisBroker"}Without Redis each process keeps its own counters, bans cache and realtime channel: rate limits multiply by the number of processes, and notifications reach only the streams of the process that sent them.
Behind a proxy
- Add the proxy networks to
PLEATE["TRUSTED_PROXIES"]soX-Forwarded-Foris used for rate limits, bans and the activity log. Only trusted hops are read. - Set
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")when TLS ends at the proxy. - The Next.js rewrite is a proxy too: keep the API private and let the browser reach it only through
/api, or expose it directly and list the frontend inCSRF_TRUSTED_ORIGINS.
Secure defaults
The base settings set SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE and SECURE_CONTENT_TYPE_NOSNIFF to True. Browsers never send secure cookies over plain http, so local development on http://localhost needs the two cookie settings off:
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUGErrors that are not API errors are logged with their traceback and answer a JSON 500 with code server_error and the request_id, never an HTML page. With DEBUG off, manage.py check warns (pleate_core.W001) when realtime still uses the in memory broker.
Health
GET /api/health checks MongoDB and, when PLEATE["REDIS_URL"] is set, Redis. It answers 200 ok or 503 degraded (MongoDB server selection times out after 5 seconds), needs no authentication, is never throttled or banned: point readiness probes at it. Logs are JSON lines on stdout with the request id of each request.
Security checklist
DEBUG = False, a long randomSECRET_KEYfrom a secret store. Rotate withSECRET_KEY_FALLBACKSso sessions and 2FA secrets keep working.- Set
PLEATE["MFA"]["ENCRYPTION_KEY"]to its own Fernet key, so rotatingSECRET_KEYnever touches TOTP secrets. SECURE_SSL_REDIRECTandSECURE_HSTS_SECONDS(the demo reads both from the environment whenDEBUGis off). Secure session and CSRF cookies are already on in the base settings.- The OpenAPI schema and Swagger UI (
/api/schema,/api/docs) answer only staff users unlessDEBUGis on; changeSPECTACULAR_SETTINGS["SERVE_PERMISSIONS"]to open or close them further. - Exact
ALLOWED_HOSTSandCSRF_TRUSTED_ORIGINS; no wildcards. - Redis as the cache, so login lockouts and throttles hold across workers. Authentication counters fail closed if it is down.
- MongoDB with authentication, TLS, a replica set and backups. GridFS files live in the same database: include them, or move files to S3.
PLEATE["WEBHOOKS"]["ALLOW_PRIVATE_URLS"]stays False.- Real email delivery with SPF, DKIM and DMARC on the sender domain; security notices and resets depend on it.
- Staff accounts with 2FA, and roles instead of
is_staffwhere a narrower permission is enough. - Run Django's deployment checks (
manage.py checkwith its deploy option) before each release.