Skip to content

Production deployment

Neither docker-compose.yml nor the repo-root Dockerfile builds a production-hardened deployment out of the box. See the warning in Docker. This page covers what changes: the process that serves requests, how static files are handled, what sits in front of Django, and which environment variables the codebase itself treats as security-relevant. It assumes you already have a working install per Manual setup or Docker and a PostgreSQL role configured per PostgreSQL and RLS.

Before you start

There is no bundled production docker-compose file, systemd unit, or process manager configuration in this repository, the Dockerfile builds an image (backend/Dockerfile puts uv-managed dependencies and the venv's bin/ on PATH, so gunicorn, celery, and python all resolve inside it), but what command that image runs is up to you: docker-compose.yml's backend service overrides it with /entrypoint.sh, which calls manage.py runserver, the Django development server. For a production container or host process you supply your own start command (see Application server below) instead of that entrypoint script.

Whatever you run it as, apply migrations and collect static files before serving traffic:

cd backend
uv run python manage.py migrate
uv run python manage.py collectstatic --noinput

Application server

backend/pyproject.toml lists gunicorn>=26.0.0 under its dependencies, commented "Production WSGI server". Gunicorn is the WSGI server this project ships as a dependency for serving the Django app itself. backend/crm/wsgi.py exposes the standard application callable, so from backend/:

cd backend
uv run gunicorn crm.wsgi:application --bind 0.0.0.0:8000

WSGI is the recommended path, and there is no longer a reason to prefer ASGI. Earlier releases required an ASGI server (uvicorn crm.asgi:application) because the in-app notifications endpoint was a Server-Sent Events stream, and a long-lived async view holds a worker hostage under WSGI. That stream was removed on 2026-08-03 in favour of polling, and it was the only async code in the project, so nothing needs ASGI now. backend/crm/asgi.py still exists and still works if you prefer it, but it buys you nothing.

Prefer WSGI for a concrete reason beyond simplicity: it bounds your database connections by construction. Under ASGI, Django runs each request in its own thread-sensitive executor with its own thread-local connection and no ceiling, so concurrent requests translate one-to-one into PostgreSQL backends. That is what caused two connection-exhaustion incidents in this project's history. Under Gunicorn, peak connections are workers × threads, a number you set on the command line:

cd backend
uv run gunicorn crm.wsgi:application --bind 0.0.0.0:8000 --workers 3 --threads 4

That example tops out at 12 database connections per host, plus whatever Celery uses. See Database connections below for pooling, which you may still want if you run many Gunicorn workers or a large thread count.

uvicorn[standard]>=0.51.0 remains a dependency so the ASGI path keeps working for anyone already deployed that way. If you serve under ASGI, read the next section before going live: bounding connections there is not optional.

Database connections

Under ASGI, Django runs each request in its own thread-sensitive executor, and each of those gets its own thread-local database connection. Nothing caps that. A burst of concurrent requests becomes a burst of PostgreSQL backends, and max_connections (default 100) is reached long before the application itself is under strain. This project has hit that in production.

Set DB_POOL_ENABLED=true to put a bounded psycopg_pool pool in front of the database. DB_POOL_MAX_SIZE (default 10) is the ceiling per process. Size it deliberately: the pool is per process, so peak usage is roughly DB_POOL_MAX_SIZE × (uvicorn workers + Celery prefork children). See Environment variables for the arithmetic and a worked example.

Two things to know before enabling it:

  • The pool requires psycopg 3. That is what this project depends on (psycopg[binary,pool]); Django raises ImproperlyConfigured if pool options are set under psycopg2. Nothing to do unless you have pinned the driver yourself.
  • Do not hand-roll the pool configuration. Tenant isolation depends on app.current_org, a session-scoped PostgreSQL setting that survives the transaction and the request. psycopg_pool does not clear session state when a connection is returned, so a pool without the project's reset callback serves one tenant's data to the next borrower of that connection. Setting DB_POOL_ENABLED=true wires the callback in for you. Writing your own OPTIONS["pool"] block without it is a cross-tenant data leak.

An external pooler is a reasonable alternative, with one hard constraint: it must run in session mode. Transaction-mode pooling (PgBouncer's default) does not keep session settings across statements, which silently breaks the RLS context this application relies on for tenant isolation.

Static files

whitenoise.middleware.WhiteNoiseMiddleware is in MIDDLEWARE (immediately after SecurityMiddleware), and STATICFILES_STORAGE is set to whitenoise.storage.CompressedManifestStaticFilesStorage. This means Django itself serves static assets, compressed and content-hashed, directly from the WSGI/ASGI process; you do not need a separate static file server or a reverse-proxy location /static/ block for correctness. Static files are collected to STATIC_ROOT (backend/staticfiles/, i.e. BASE_DIR/staticfiles) under STATIC_URL = "/static/" by manage.py collectstatic, which you must run yourself. Gunicorn does not run it for you (the Docker Compose entrypoint does, but only because docker/backend/entrypoint.sh calls it explicitly on every start).

Media (user-uploaded files: invoice attachments, case attachments, and similar) is separate from static files and is not covered by WhiteNoise. With ENV_TYPE=dev (the default), MEDIA_ROOT/MEDIA_URL are a local media/ directory served through Django itself; with ENV_TYPE=prod, backend/crm/server_settings.py is imported instead and switches DEFAULT_FILE_STORAGE to storages.backends.s3boto3.S3Boto3Storage, reading AWS_BUCKET_NAME, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SES_REGION_NAME, AWS_SES_REGION_ENDPOINT and SENTRY_DSN from the environment with os.environ[...]: a plain KeyError at import time, and the process refusing to start, if any of them are unset. It also switches EMAIL_BACKEND to django_ses.SESBackend and hardcodes SESSION_COOKIE_DOMAIN = ".bottlecrm.io". That domain is specific to the project's own SaaS hosting, not to your deployment. Setting ENV_TYPE=prod without also being ready to supply AWS S3, SES and Sentry credentials, and without meaning to scope session cookies to bottlecrm.io, will not do what you want. See Required settings below for the practical consequence.

Reverse proxy

Nothing in backend/crm/settings.py terminates TLS, and SECURE_PROXY_SSL_HEADER is not set anywhere in this codebase's settings modules. Concretely: if you terminate TLS at a reverse proxy (nginx, Caddy, or similar) and forward plain HTTP to Gunicorn/Uvicorn, Django's request.is_secure() will report False for every request unless you add SECURE_PROXY_SSL_HEADER yourself (typically pointing at the X-Forwarded-Proto header your proxy sets). This project does not configure that for you. SECURE_HSTS_SECONDS = 31536000 (one year), SECURE_HSTS_INCLUDE_SUBDOMAINS = True, SECURE_HSTS_PRELOAD = True and SECURE_CONTENT_TYPE_NOSNIFF = True are all set unconditionally in backend/crm/settings.py, but Django's SecurityMiddleware only emits the Strict-Transport-Security header on requests it considers secure, so without SECURE_PROXY_SSL_HEADER configured correctly for your proxy, that HSTS configuration silently never takes effect.

Point your proxy at whichever port your application server binds (8000 in the examples above), and route / there. There's no repo-provided proxy config (nginx site file, Caddyfile, or similar) to point to. The specifics are yours to write.

Required settings

backend/crm/settings.py reads these from the environment at import time (i.e. at process startup, not per-request):

Variable Behavior
SECRET_KEY Defaults to the literal string "django-insecure-dev-key-please-change-in-production" if unset. The module itself checks this: if SECRET_KEY is empty or starts with "django-insecure" and ENV_TYPE != "dev", it raises ValueError("SECRET_KEY must be set to a secure value in non-dev environments") at import time, a hard startup failure, not a warning. This guard only fires when ENV_TYPE is set to something other than dev; given the ENV_TYPE=prod consequences described above (S3/SES/Sentry env vars becoming mandatory, the hardcoded .bottlecrm.io cookie domain), don't rely on that check alone. Set a real, unique SECRET_KEY regardless of ENV_TYPE.
DEBUG Must be False in production. Read as os.environ.get("DEBUG", "False").lower() == "true", defaults to False already, so the failure mode to avoid is explicitly setting DEBUG=True (as .env.docker does for local development) and carrying that into production.
ALLOWED_HOSTS A comma-separated environment variable: os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(","). Set it to the hostname(s) you'll actually be served on, e.g. ALLOWED_HOSTS=crm.example.com.
CSRF_TRUSTED_ORIGINS Also comma-separated: os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(","), empty by default. Django requires each entry to include a scheme (e.g. https://crm.example.com), not a bare hostname.

A few more the same module reads, worth setting deliberately rather than leaving at their defaults: CORS_ALLOWED_ORIGINS (comma-separated, defaults to the two local dev frontend origins) and CORS_ALLOW_ALL (CORS_ORIGIN_ALLOW_ALL, .env.docker sets this True for local development. Leave it False in production and rely on CORS_ALLOWED_ORIGINS instead), and and FRONTEND_URL, which is interpolated into every emailed link (magic-link sign-in, the customer's invoice and estimate portal, the CSAT survey, internal notifications) and so needs to point at your real, public hostname. FRONTEND_URL is now checked at startup: with ENV_TYPE set to anything but dev, a loopback or non-absolute value raises rather than mailing a dead link to a customer. DOMAIN_NAME no longer reaches any emitted link and can be left alone.