A Django REST Framework backend for a multi-tenant SaaS product: JWT and API-key authentication, subscription tiers with per-plan quotas and throttling, and request analytics with pre-aggregated daily rollups. Runs on Docker with PostgreSQL, Redis, and Celery.
The API surface is documented by a generated OpenAPI 3 schema, and the test suite runs against a real PostgreSQL database in CI on every push.
The generated OpenAPI schema, served by the running application:
Swagger UI (/api/docs/) |
Redoc (/api/redoc/) |
|---|---|
![]() |
![]() |
- Clean architecture: views → service layer → selectors → models. Business logic is reusable from CLI, Celery tasks, and admin.
- JWT authentication (SimpleJWT) with refresh-token rotation, blacklist on logout, and device/session tracking.
- Hashed credential storage: email verification tokens, password reset tokens, and API keys are all SHA-256 hashed in the database. Plaintext API keys are shown exactly once.
- Subscription system with Free/Pro/Enterprise tiers, per-period usage quotas, plan-aware feature flags, and a nightly expiry sweep.
- Plan-scoped rate limiting via DRF throttling backed by Redis — each plan gets its own bucket.
- Append-only request audit log + nightly pre-aggregated daily rollup; dashboard queries hit the rollup, not the raw logs.
- OpenAPI 3 schema auto-generated by drf-spectacular. Swagger UI and Redoc served out of the box.
- Dockerized: web, worker, beat, postgres, redis, mailhog all defined. Production compose adds nginx.
- Centralized error envelope: every error response is
{"error": {"code", "message", "details"}}for frontends to parse uniformly. - Request tracing: every response carries an
X-Request-IDheader; every log line carries the same ID.
| Layer | Choice |
|---|---|
| Runtime | Python 3.12 |
| Web framework | Django 5 + Django REST Framework 3.15 |
| Auth | djangorestframework-simplejwt + API key auth class |
| Database | PostgreSQL 16 (via psycopg 3) |
| Cache / broker | Redis 7 |
| Background jobs | Celery 5 + django-celery-beat |
| Docs | drf-spectacular (OpenAPI 3, Swagger UI, Redoc) |
| Reverse proxy | Nginx (prod) |
| App server | Gunicorn (prod) |
| Tests | pytest + pytest-django + factory-boy |
| Errors | Sentry (prod) |
HTTP request
│
▼
[ Nginx ] ──► [ Gunicorn ]
│
▼
Django middleware stack
├── RequestIDMiddleware (tracing)
├── RequestLoggingMiddleware (access logs)
├── RequestAnalyticsMiddleware (audit + quota)
└── SubscriptionContextMiddleware (attaches plan to request)
│
▼
DRF view (HTTP only)
│
▼
Service layer (business logic, transactions)
│
├──► Selectors → ORM (read)
├──► Models → ORM (write)
└──► Celery → async work (email, rollups, expiry)
│
▼
Redis broker → worker / beat
App boundaries are bounded contexts, not Django conventions:
apps/
├── common/ Shared base models, exceptions, middleware, permissions, throttling
├── users/ User identity, profile, RBAC
├── authentication/ Registration, login, JWT, tokens, sessions, password reset
├── subscriptions/ Plans, subscriptions, usage quotas, billing state
├── api_keys/ Hashed key issuance, rotation, revocation, DRF auth class
├── analytics/ Request log, daily rollups, aggregation queries
└── dashboard/ Read-only endpoints composing the above for a UI
User(UUID PK, email, role enum, is_verified)EmailVerificationToken/PasswordResetToken(hashed, one-shot, TTL)UserSession(per-refresh-token; revoke/list endpoints)Plan(slug, price, request_quota, max_api_keys, features JSON)Subscription(user → plan; partial unique constraint enforces one active per user)UsageQuota(per subscription period; atomic F() increments)APIKey(user → key; prefix + sha256 hash, last_used_at)RequestLog(append-only, indexed on created_at)DailyUsage(rollup; unique on (user, date))
cp .env.example .env # adjust as needed
docker compose up --build # postgres, redis, mailhog, web, worker, beatWait for the web container's startup log. Then:
| Service | URL |
|---|---|
| API root | http://localhost:8001/api/v1/ |
| Swagger UI | http://localhost:8001/api/docs/ |
| Redoc | http://localhost:8001/api/redoc/ |
| OpenAPI schema | http://localhost:8001/api/schema/ |
| Admin | http://localhost:8001/admin/ |
| Mailhog UI | http://localhost:8025/ |
Migrations and plan seeding run automatically on web container startup (controlled by RUN_MIGRATIONS=true).
Ports 5432 and 6379 are commonly taken by another project's database. Every host-side
port is configurable in .env, so you can move this stack out of the way without
touching anything else:
POSTGRES_HOST_PORT=5442
REDIS_HOST_PORT=6389
WEB_HOST_PORT=8011Only the host mapping changes — containers still reach each other on the standard ports over the compose network, so no application configuration needs to change.
Create a superuser:
docker compose exec web python manage.py createsuperuserpython3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements/development.txt
# Start Postgres + Redis + MailHog any way you like (Docker, brew, etc.)
# Then:
cp .env.example .env
export $(grep -v '^#' .env | xargs)
export POSTGRES_HOST=localhost
export REDIS_URL=redis://localhost:6379/0
export CELERY_BROKER_URL=redis://localhost:6379/1
export EMAIL_HOST=localhost
python manage.py migrate
python manage.py seed_plans
python manage.py runserverIn separate shells:
celery -A config worker --loglevel=info
celery -A config beat --loglevel=info --scheduler django_celery_beat.schedulers:DatabaseSchedulerAll endpoints under /api/v1/. Full schemas at /api/docs/.
| Method | Path | Description |
|---|---|---|
| POST | /auth/register/ |
Sign up. Sends verification email. |
| POST | /auth/login/ |
Obtain JWT pair. Rejects unverified accounts. |
| POST | /auth/token/refresh/ |
Refresh access token (rotates refresh; blacklists previous). |
| POST | /auth/logout/ |
Blacklist refresh token; revoke session. |
| POST | /auth/verify-email/ |
Body: {"token": "..."} |
| POST | /auth/resend-verification/ |
Body: {"email": "..."} |
| POST | /auth/password/reset/ |
Body: {"email": "..."} |
| POST | /auth/password/reset/confirm/ |
Body: {"token", "new_password"} |
| GET | /auth/sessions/ |
List active sessions. |
| POST | /auth/sessions/{id}/revoke/ |
Revoke a session. |
| Method | Path | Description |
|---|---|---|
| GET/PATCH | /users/me/ |
Get/update current user. |
| POST | /users/me/change-password/ |
Change password (current + new). |
| GET | /users/ |
List users (Staff/Admin only). |
| Method | Path | Description |
|---|---|---|
| GET | /subscriptions/plans/ |
Public plan catalog. |
| GET | /subscriptions/me/ |
Current subscription + quota. |
| POST | /subscriptions/subscribe/ |
Body: {"plan_slug"} — subscribe / upgrade / downgrade. |
| POST | /subscriptions/cancel/ |
Cancel and downgrade to Free. |
| Method | Path | Description |
|---|---|---|
| GET/POST | /api-keys/ |
List, or create a new key (plaintext returned once). |
| POST | /api-keys/{id}/revoke/ |
Revoke a key. |
| POST | /api-keys/{id}/rotate/ |
Revoke + issue replacement. |
| Method | Path | Description |
|---|---|---|
| GET | /dashboard/overview/ |
One-call overview: user, subscription, usage, key count. |
| GET | /dashboard/usage/summary/?days=30 |
Headline metrics. |
| GET | /dashboard/usage/daily/?days=30 |
Zero-filled daily chart series. |
| GET | /dashboard/usage/monthly/?months=6 |
Monthly bar chart series. |
| GET | /dashboard/requests/recent/?limit=20 |
Last N requests. |
Two equivalent schemes are accepted on protected endpoints:
Authorization: Bearer <JWT access token>or
X-API-Key: sk_live_<your_key>All configuration is environment-driven (django-environ). See .env.example. Key variables:
| Variable | Purpose |
|---|---|
DJANGO_SETTINGS_MODULE |
config.settings.development | production | test |
DJANGO_SECRET_KEY |
Required. Long random string. |
DJANGO_DEBUG |
False in production. |
DJANGO_ALLOWED_HOSTS |
Comma-separated hostnames. |
POSTGRES_* |
DB credentials. |
REDIS_URL, CELERY_BROKER_URL, CELERY_RESULT_BACKEND |
Redis URLs. |
JWT_ACCESS_TOKEN_LIFETIME_MINUTES |
Default 15. |
JWT_REFRESH_TOKEN_LIFETIME_DAYS |
Default 7. |
EMAIL_* |
SMTP config. |
FRONTEND_URL |
Used to build verification / reset links. |
CORS_ALLOWED_ORIGINS |
Comma-separated allowed origins. |
SENTRY_DSN |
Optional, prod only. |
subscriptions.expire_due_subscriptions— sweep nightly; mark expired, downgrade to Free.analytics.rollup_daily_usage— nightly rollup of yesterday'sRequestLogintoDailyUsage.authentication.send_verification_email/send_password_reset_email— dispatched on demand with exponential-backoff retry.
Schedule them in the Django admin under Periodic tasks (django-celery-beat lets you edit schedules without redeploying).
Every request emits a structured access log line:
[2026-05-16 12:34:56,789] INFO apps.common.middleware req=a1b2c3 :: POST /api/v1/auth/login/ -> 200 in 42ms
The request_id is also returned in the X-Request-ID response header, so frontend logs and backend logs can be correlated.
All errors look like this:
{
"error": {
"code": "quota_exceeded",
"message": "Monthly quota of 1000 requests exceeded for plan 'free'.",
"details": {"plan": "free", "limit": 1000, "used": 1000}
}
}- Argon2 password hashing.
- Strong-password validator (10+ chars, letter + digit + symbol).
- All tokens stored as SHA-256 hashes; plaintext only ever in transit.
- Constant-time hash comparison on API key auth path.
- JWT refresh-token rotation + blacklist.
- One-active-subscription DB constraint (partial unique index).
- CORS restricted by env var; HSTS + secure cookies enabled in production.
- SECURE_PROXY_SSL_HEADER set for HTTPS detection behind nginx/ALB.
- Sentry integration for production error capture.
docker compose exec web pytest
# or locally
DJANGO_SETTINGS_MODULE=config.settings.test pytestTest layout:
tests/
├── conftest.py fixtures: api_client, auth_client, seeded plans
├── factories.py factory-boy: User, Plan, Subscription, APIKey
├── test_authentication.py
├── test_subscriptions.py
├── test_api_keys.py
├── test_dashboard.py
└── test_common.py
The docker-compose.prod.yml file defines the prod topology:
nginx (80) → gunicorn (web) → postgres
│
├─→ redis ←─ celery worker
│ ←─ celery beat
└─→ sentry (errors)
Build and run:
DJANGO_SETTINGS_MODULE=config.settings.production docker compose -f docker-compose.prod.yml up -d --buildProduction checklist:
- Set a strong, unique
DJANGO_SECRET_KEY. - Set
DJANGO_DEBUG=False. - Configure
DJANGO_ALLOWED_HOSTSfor your domain. - Configure
CORS_ALLOWED_ORIGINSfor your frontend domain. - Real SMTP (
EMAIL_HOST,EMAIL_HOST_USER,EMAIL_HOST_PASSWORD,EMAIL_USE_TLS=True). - Configure
SENTRY_DSN. - Terminate TLS at nginx (or upstream ALB) and ensure
X-Forwarded-Proto: httpsis set. - Set up Celery beat schedules in
/adminforexpire_due_subscriptionsandrollup_daily_usage. - Configure
RequestLogretention (Celery purge task — easy to add). - Database backups & PITR for Postgres.
- Monitor
/health/from your load balancer.
saas_backend/
├── apps/ Bounded-context applications
├── config/ Django project (settings, urls, celery, wsgi/asgi)
├── nginx/ Production reverse-proxy config
├── requirements/ base / development / production
├── scripts/ entrypoint.sh, start-prod.sh
├── templates/emails/ HTML + plaintext email templates
├── tests/ Pytest suite
├── docker-compose.yml Dev stack
├── docker-compose.prod.yml Prod stack
├── Dockerfile Multi-stage; non-root runtime user
├── manage.py
├── pytest.ini
├── pyproject.toml
└── Makefile
MIT.

