HyperSaaS
BackendDeployment

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 = True

Cookies

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 = True

HSTS

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 = True

Other

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  # 100MB

Storage 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.

Email

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:

ProviderBackend
Resendanymail.backends.resend.EmailBackend
Amazon SESanymail.backends.amazon_ses.EmailBackend
SendGridanymail.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 = True

Beat 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

VariableDescription
DJANGO_SECRET_KEYDjango secret key
DJANGO_JWT_PRIVATE_KEYSigns the API's tokens: python manage.py generate_jwt_key. Changing it signs everyone out
DJANGO_ALLOWED_HOSTSComma-separated allowed hosts
DATABASE_URLPostgreSQL connection string; or POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB, POSTGRES_USER and POSTGRES_PASSWORD, from which the settings build it
REDIS_URLRedis connection string. Also where sessions are read on every API request
DJANGO_AWS_STORAGE_BUCKET_NAMES3 bucket name
STRIPE_API_SECRETStripe secret key
STRIPE_WEBHOOK_SECRETStripe webhook signing secret
STRIPE_PUBLISHABLE_KEYStripe publishable key
OPENAI_API_KEYEmbeddings, search rewriting and reranking, titles, summaries, transcription, and every plan's default chat model
DJANGO_DEFAULT_FROM_EMAILThe sender of every email, on a domain verified with your email provider
FRONT_END_BASE_URLFrontend URL

Optional

VariableDefaultDescription
DJANGO_SECURE_SSL_REDIRECTTrueSSL redirect
DJANGO_SECURE_HSTS_SECONDS60How 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_CONCURRENCY2Worker processes; each one reading a document needs a few GB
SENTRY_DSN—Sentry error tracking
SENTRY_ENVIRONMENTproductionSentry environment tag
SENTRY_TRACES_SAMPLE_RATE0.0Performance 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 VariablesPer-user and per-account rate limits
MFA_TOTP_ISSUERHyperSaaSThe 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:

  1. Migrate: python manage.py migrate.
  2. Create the plans in live Stripe: with the live STRIPE_API_SECRET, run python 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.
  3. 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 as STRIPE_WEBHOOK_SECRET. See Webhooks.
  4. Block public access on the S3 bucket. Documents are only ever served through presigned URLs, so nothing needs the bucket to be public.
  5. Check the tool prices in TOOL_CALL_PRICES (workspaces/usage.py) match your SerpApi, Google Maps and WeatherAPI plans.
  6. Create an admin: python manage.py createsuperuser.
  7. Name the site in the admin (Sites): account emails use its name and domain.

Upgrading an Existing Install

  • Run migrate, then sync_stripe_plans as 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_KEY before deploying; everyone signs in again once. Before upgrading, run python manage.py migrate token_blacklist zero on the old version: those tables are no longer used.
  • Remove STRIPE_ENDPOINT_SECRET and STRIPE_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.

On this page