Production Settings
Security, storage, caching, and email configuration for production.
Security
SSL & HTTPS
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = TrueCookies
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_NAME = "__Secure-sessionid"
SESSION_COOKIE_HTTPONLY = True
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_NAME = "__Secure-csrftoken"
CSRF_COOKIE_HTTPONLY = TrueHSTS
SECURE_HSTS_SECONDS = env.int("DJANGO_SECURE_HSTS_SECONDS", default=60) # raise it (31536000 is a year) once HTTPS is settled
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_CONTENT_TYPE_NOSNIFF = TrueOther
X_FRAME_OPTIONS = "DENY"Password Hashing
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher", # Primary
"django.contrib.auth.hashers.PBKDF2PasswordHasher", # Fallback
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
"django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
]CORS
CORS_URLS_REGEX = r"^/api/.*$" # Only API routes
CORS_ALLOWED_ORIGINS = [
"http://localhost:3000",
"http://127.0.0.1:3000",
"https://www.yourdomain.com",
"https://yourdomain.com",
]Replace the example domains in CORS_ALLOWED_ORIGINS (config/settings/base.py) with your frontend's. The frontend calls the API from its own server, so CORS only matters for browsers calling the API directly.
AWS / S3 Storage
Configuration
# Leave both unset on AWS (ECS): the task's IAM role is used instead.
AWS_ACCESS_KEY_ID = env("DJANGO_AWS_ACCESS_KEY_ID", default=None)
AWS_SECRET_ACCESS_KEY = env("DJANGO_AWS_SECRET_ACCESS_KEY", default=None)
AWS_STORAGE_BUCKET_NAME = env("DJANGO_AWS_STORAGE_BUCKET_NAME")
AWS_S3_REGION_NAME = env("DJANGO_AWS_S3_REGION_NAME", default=None)
AWS_S3_CUSTOM_DOMAIN = env("DJANGO_AWS_S3_CUSTOM_DOMAIN", default=None)
AWS_QUERYSTRING_AUTH = True # signed URLs: the bucket is private
AWS_S3_MAX_MEMORY_SIZE = 100 * 1024 * 1024 # 100MBStorage Backends
STORAGES = {
"default": {
"BACKEND": "storages.backends.s3.S3Storage",
"OPTIONS": {"location": "media"},
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}Uploaded files are stored on S3 under media/; documents use signed upload URLs under documents/, and chat attachments under chat-attachments/ (deleted after a day on AWS). The bucket stays private: files go in and out through signed URLs. Static files (the admin and the browsable API) are collected into the web image at build time and served by WhiteNoise, so they always match the running code.
Caching Headers
AWS_S3_OBJECT_PARAMETERS = {
"CacheControl": f"max-age={60*60*24*7}, s-maxage={60*60*24*7}, must-revalidate"
}Files are cached for 7 days once fetched.
Caching
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"IGNORE_EXCEPTIONS": True,
},
}
}Redis is used for both Celery broker and Django cache. IGNORE_EXCEPTIONS = True ensures the application doesn't crash if Redis is temporarily unavailable.
Resend (Default)
EMAIL_BACKEND = "anymail.backends.resend.EmailBackend"
RESEND_API_KEY = env("RESEND_API_KEY")
DEFAULT_FROM_EMAIL = env("DJANGO_DEFAULT_FROM_EMAIL", default="HyperSaaS <noreply@hypersaas.dev>")Set the sender to an address on a domain you've verified in Resend. Resend refuses to send from any other domain, and sign-up fails, because the activation email is sent during the request. On AWS, Terraform sets it from email_from.
Alternative Providers
HyperSaaS supports multiple email backends via django-anymail:
| Provider | Backend |
|---|---|
| Resend | anymail.backends.resend.EmailBackend |
| Amazon SES | anymail.backends.amazon_ses.EmailBackend |
| SendGrid | anymail.backends.sendgrid.EmailBackend |
| Console (dev) | django.core.mail.backends.console.EmailBackend |
Celery
Production Worker
The worker runs from the worker image (it has Docling) and listens on two queues: celery for documents and summaries, and attachments for reading chat attachments, so those never wait behind document indexing. It runs CELERY_WORKER_CONCURRENCY processes (2 by default): each one reading a document with Docling needs a few GB of memory.
Configuration
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
CELERY_TASK_TIME_LIMIT = 5 * 60 # 5 minutes
CELERY_TASK_SOFT_TIME_LIMIT = 60 # 1 minute
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_WORKER_SEND_TASK_EVENTS = TrueBeat Scheduler
Celery Beat uses DatabaseScheduler — periodic tasks are stored in the database and manageable via Django Admin.
Flower
Flower provides real-time Celery monitoring:
CELERY_FLOWER_USER = env("CELERY_FLOWER_USER")
CELERY_FLOWER_PASSWORD = env("CELERY_FLOWER_PASSWORD")Flower isn't part of the AWS deployment. Run it locally, or on the one-server setup, where it's protected with basic auth.
Environment Variables
Required
| Variable | Description |
|---|---|
DJANGO_SECRET_KEY | Django secret key |
DJANGO_JWT_PRIVATE_KEY | Signs the API's tokens: python manage.py generate_jwt_key. Changing it signs everyone out |
DJANGO_ALLOWED_HOSTS | Comma-separated allowed hosts |
DATABASE_URL | PostgreSQL connection string; or POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB, POSTGRES_USER and POSTGRES_PASSWORD, from which the settings build it |
REDIS_URL | Redis connection string. Also where sessions are read on every API request |
DJANGO_AWS_STORAGE_BUCKET_NAME | S3 bucket name |
STRIPE_API_SECRET | Stripe secret key |
STRIPE_WEBHOOK_SECRET | Stripe webhook signing secret |
STRIPE_PUBLISHABLE_KEY | Stripe publishable key |
OPENAI_API_KEY | Embeddings, search rewriting and reranking, titles, summaries, transcription, and every plan's default chat model |
DJANGO_DEFAULT_FROM_EMAIL | The sender of every email, on a domain verified with your email provider |
FRONT_END_BASE_URL | Frontend URL |
Optional
| Variable | Default | Description |
|---|---|---|
DJANGO_SECURE_SSL_REDIRECT | True | SSL redirect |
DJANGO_SECURE_HSTS_SECONDS | 60 | How long browsers remember to use HTTPS only |
DJANGO_AWS_ACCESS_KEY_ID, DJANGO_AWS_SECRET_ACCESS_KEY | — | S3 credentials outside AWS. On AWS, leave them unset: the task's IAM role is used |
GOOGLE_API_KEY | — | Gemini models |
CELERY_WORKER_CONCURRENCY | 2 | Worker processes; each one reading a document needs a few GB |
SENTRY_DSN | — | Sentry error tracking |
SENTRY_ENVIRONMENT | production | Sentry environment tag |
SENTRY_TRACES_SAMPLE_RATE | 0.0 | Performance monitoring sample rate |
DJANGO_AWS_S3_REGION_NAME | — | AWS region |
DJANGO_AWS_S3_CUSTOM_DOMAIN | — | CloudFront domain |
RESEND_API_KEY | — | Email provider API key |
CELERY_FLOWER_USER | — | Flower dashboard username |
CELERY_FLOWER_PASSWORD | — | Flower dashboard password |
ANTHROPIC_API_KEY | — | Anthropic LLM provider |
SERPAPI_API_KEY | — | Web search tool |
WEATHERAPI_COM_API_KEY | — | Weather tool |
GOOGLE_MAPS_API_KEY | — | Location tool |
LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY | — | Chat tracing in Langfuse |
CELERY_WORKER_MAX_MEMORY_PER_CHILD | — | Replace worker processes that grow past this many KB; document parsing can use several GB |
THROTTLE_* | See Environment Variables | Per-user and per-account rate limits |
MFA_TOTP_ISSUER | HyperSaaS | The name authenticator apps show next to the code |
Going Live
On AWS, AWS: first deployment covers all of this: Deploy runs the migrations, and Terraform creates the bucket private. Elsewhere:
- Migrate:
python manage.py migrate. - Create the plans in live Stripe: with the live
STRIPE_API_SECRET, runpython manage.py sync_stripe_plans --dry-run, check the output, then run it without--dry-run. It also sets the billing portal to offer your plans. See Plans. - Add the webhook endpoint in the live Stripe dashboard,
https://your-domain.com/api/subscriptions/webhook(no trailing slash), with the subscription, product and price events, and set its signing secret asSTRIPE_WEBHOOK_SECRET. See Webhooks. - Block public access on the S3 bucket. Documents are only ever served through presigned URLs, so nothing needs the bucket to be public.
- Check the tool prices in
TOOL_CALL_PRICES(workspaces/usage.py) match your SerpApi, Google Maps and WeatherAPI plans. - Create an admin:
python manage.py createsuperuser. - Name the site in the admin (Sites): account emails use its name and domain.
Upgrading an Existing Install
- Run
migrate, thensync_stripe_plansas above. Products from before plans existed are archived, and their subscribers count as Free until they choose a plan. - Signing in moved to django-allauth's API mode, with its own tokens. Set
DJANGO_JWT_PRIVATE_KEYbefore deploying; everyone signs in again once. Before upgrading, runpython manage.py migrate token_blacklist zeroon the old version: those tables are no longer used. - Remove
STRIPE_ENDPOINT_SECRETandSTRIPE_PRODUCT_ID; nothing reads them. - Run
python manage.py delete_orphaned_document_files --dry-run, then without the flag, to remove stored files that no document points to any more.