Backend

Deployment

Environment variables, processes, Redis, SSE and a security checklist for going to production.

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

VariableExampleUsed for
SECRET_KEY50+ random charactersSigning sessions, tokens and, by default, the 2FA encryption key.
DEBUG0Never 1 in production.
ALLOWED_HOSTSapi.plate.app,plate.appHosts Django answers.
CSRF_TRUSTED_ORIGINShttps://plate.appOrigins allowed to send unsafe requests.
MONGODB_URImongodb+srv://…/plateThe database (replica set, TLS, a dedicated user).
REDIS_URLrediss://…:6379/0Cache, rate limits, Celery broker, realtime.
FRONTEND_URLhttps://plate.appLinks in emails and redirects.
DEFAULT_FROM_EMAILPlate <hello@plate.app>Sender.
SMTP_HOST / SMTP_PORTsmtp.example.com / 587Or an anymail API key.
ANTHROPIC_API_KEYsk-ant-…AI chat (or OPENAI_API_KEY).
GOOGLE_CLIENT_ID / _SECRET…OAuth, one pair per provider.
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRETsk_live_… / whsec_…Billing.
PLEATE_API_ORIGINhttp://api:8000On the Next.js side: where the /api rewrite goes.

Processes

sh
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 one
gunicorn.conf.py
bind = "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 timeout

Server-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) and Cache-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

settings.py
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"] so X-Forwarded-For is 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 in CSRF_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:

settings.py
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG

Errors 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 random SECRET_KEY from a secret store. Rotate with SECRET_KEY_FALLBACKS so sessions and 2FA secrets keep working.
  • Set PLEATE["MFA"]["ENCRYPTION_KEY"] to its own Fernet key, so rotating SECRET_KEY never touches TOTP secrets.
  • SECURE_SSL_REDIRECT and SECURE_HSTS_SECONDS (the demo reads both from the environment when DEBUG is 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 unless DEBUG is on; change SPECTACULAR_SETTINGS["SERVE_PERMISSIONS"] to open or close them further.
  • Exact ALLOWED_HOSTS and CSRF_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_staff where a narrower permission is enough.
  • Run Django's deployment checks (manage.py check with its deploy option) before each release.