bottleneckcrm/docs/self-hosting/production-deploy.md
Rostyslav Kravchuk 8dc219da1a feat(email): configure outbound mail through MAILERS and allow SMTP from env
Move outbound email off Django's deprecated EMAIL_* settings onto Django
6.1's MAILERS setting, and make the SMTP backend configurable from env
vars (EMAIL_HOST/PORT/HOST_USER/HOST_PASSWORD/USE_TLS/USE_SSL/TIMEOUT).
Auth-email Celery tasks now catch OSError alongside ClientError so an
SMTP failure is logged instead of raising. Documents Mailtrap as the
example SMTP provider.
2026-10-16 20:04:24 +03:00

178 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](docker.md). 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](../getting-started/manual-setup.md)
or [Docker](docker.md) and a PostgreSQL role configured per
[PostgreSQL and RLS](postgresql-and-rls.md).
## 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](#application-server) below) instead of that entrypoint script.
Whatever you run it as, apply migrations and collect static files before serving traffic:
```bash
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/`:
```bash
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:
```bash
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](#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](../reference/environment-variables.md#connection-pooling)
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
`MAILERS["default"]["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](#required-settings) below for the practical consequence.
## Reverse proxy
Nothing in `backend/crm/settings.py` terminates TLS, and `SECURE_PROXY_SSL_HEADER` is set only
when you opt in with `TRUST_PROXY_SSL_HEADER=true` (see
[Environment variables](../reference/environment-variables.md)). 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 that variable is set and your
proxy overwrites `X-Forwarded-Proto`. `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, with one requirement: the proxy's access
log records the calendar feed, invoice, estimate and survey links, whose path carries a working
credential. The application redacts its own logs but cannot reach the proxy's. See
[Public-link tokens in logs](security-hardening.md#public-link-tokens-in-logs) for an nginx
`log_format` that does it.
The URLs the API hands out for use somewhere else are built from `DOMAIN_NAME`, not from the request
that reached it: the web form embed snippets and the form's submit URL and, from django-crm 1.13.0,
the task calendar feed URL (`/api/public/calendar/<token>.ics`, see
[API: Tasks](../api/tasks.md#calendar-feed)). The feed URL is pasted into Google Calendar or
Outlook, which fetch it from the internet, and the snippet onto a customer's site, so
`DOMAIN_NAME` has to be the API's public origin, for example `https://api.example.com`. The web app
asks for these from its own server at `PUBLIC_DJANGO_API_URL`, which may be an internal address such
as the compose file's `http://backend:8000`; that no longer leaks into the URLs it shows. With
`ENV_TYPE` set to anything but `dev`, the backend refuses to start when `DOMAIN_NAME` is a loopback
or non-absolute URL.
## 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`, the API's own public origin, is checked the same way (see
above).