bottleneckcrm/docs/reference/environment-variables.md

17 KiB
Raw Blame History

Environment variables

This page is the exhaustive table: every environment variable backend/crm/settings.py reads, its real default, and what it's for. Self-hosting: Environment variables is the narrative companion. How configuration loading actually works (load_dotenv(), the .env.docker / .env.docker.local layering), and the smaller, curated list of variables an operator actually has to touch to run a working, non-default install. Read that page first if you're setting up a deployment; come back here when you need the complete list or an exact default.

How to set them

Every variable in the table below is read with os.environ.get("NAME", <default>) (or the equivalent os.getenv) directly in crm/settings.py, at process import time: manage.py runserver, migrate, a Gunicorn/Uvicorn worker, a Celery worker, and every management command all read the same values the same way. A .env file next to manage.py (for a manual install) or .env.docker/.env.docker.local (for the Docker Compose stack) is loaded into the process environment before any of these calls run, so an unset variable falls back to the default in the Default column rather than crashing immediately. That's true even for SECRET_KEY: its own os.environ.get call always succeeds, but the value it produces then feeds a separate check a few lines later that raises ValueError once ENV_TYPE is anything other than dev (see the SECRET_KEY row below): a default that satisfies os.environ.get doesn't necessarily satisfy everything downstream of it. The variables with genuinely no default in code. Read with plain os.environ[...], so a missing one is an immediate KeyError rather than a fallback. Live outside crm/settings.py entirely and are covered in the list below instead of the table.

Three groups of real, project-read environment variables are not read by crm/settings.py itself, so they aren't in the table below:

  • ADMIN_PASSWORD is read directly by the create_default_admin management command (see Management commands). It still gets its own row below because it's part of the same Docker bootstrap flow as ADMIN_EMAIL, which is read in settings.py.
  • AWS_BUCKET_NAME, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, SENTRY_DSN are read with plain os.environ[...] (no default) inside backend/crm/server_settings.py, which crm/settings.py only imports when ENV_TYPE=prod. A missing one is a KeyError at import time. The process refuses to start rather than starting half-configured. ENV_TYPE itself follows the same rule: it's read in settings.py, but it's the internal dev/prod switch this project's own tests exclude from the table by design, not a self-hoster-facing setting on its own. See Production deployment and Security hardening for what ENV_TYPE=prod actually requires and why it shouldn't be set casually.
  • BCRM_MCP_ENABLED and BCRM_BASE_URL are no longer read anywhere. They gated the MCP server that used to be mounted at /mcp, which has been removed in favour of the REST API. Both are safe to delete from an existing deployment's environment. See AI agents for how an agent connects now.

Reference

Django core

Variable Default Required Purpose
SECRET_KEY django-insecure-dev-key-please-change-in-production Yes, once ENV_TYPE is anything other than dev (see above) Django's cryptographic signing key. It also is the JWT SIGNING_KEY: SIMPLE_JWT["SIGNING_KEY"] is set to SECRET_KEY directly, so there is no separate JWT signing key setting anywhere in this codebase, and rotating SECRET_KEY invalidates every outstanding access and refresh token. It must be at least 32 bytes. HS256 signs the JWTs, and RFC 7518 section 3.2 requires an HMAC key at least as long as the hash output; a shorter one raises at import time outside dev and warns inside it. Generate one with python -c "import secrets; print(secrets.token_urlsafe(48))". See Architecture: Authentication and Security hardening.
DEBUG False No Django debug mode. Also the sole gate on the devlogin management command. See Management commands.
ALLOWED_HOSTS localhost,127.0.0.1 Yes, once serving traffic under a real hostname Comma-separated list, split on , into Django's ALLOWED_HOSTS.
TRUST_PROXY_SSL_HEADER False Yes, if TLS is terminated by a proxy in front of Django Set to true (case-insensitive) to set Django's SECURE_PROXY_SSL_HEADER to ("HTTP_X_FORWARDED_PROTO", "https"). Without it, a request arriving from nginx or an ALB over plain HTTP makes request.is_secure() false, and SecurityMiddleware then emits no Strict-Transport-Security header at all, so the SECURE_HSTS_SECONDS / INCLUDE_SUBDOMAINS / PRELOAD settings are silently inert. Only turn it on when that proxy is the sole route to the app and it overwrites X-Forwarded-Proto rather than passing through a client-supplied one, because otherwise any caller reaching Django directly can make request.is_secure() return true. The http-to-https redirect belongs on the proxy; SECURE_SSL_REDIRECT is deliberately not set.
DJANGO_ORG_API_KEY_AUTH true No Set to false, 0, no or off to refuse the organization API key (Token: <org.api_key>) as an authentication method entirely, on every endpoint. The key is one non-expiring credential per tenant that resolves to an arbitrary active ADMIN, so it cannot be revoked per-integration and it reads every record in the org. It is read-only and barred from the credential endpoints even when enabled (see Tokens and API keys), but a deployment whose integrations have all moved to personal access tokens should turn it off. Left on by default so an upgrade breaks nothing.

Database

Variable Default Required Purpose
DBNAME crm_db No PostgreSQL database name.
DBUSER postgres Yes, before anyone but you can reach the instance PostgreSQL role. The default is the PostgreSQL superuser account on most installations, and a superuser silently bypasses every RLS policy this project relies on for tenant isolation. See PostgreSQL and RLS: Creating the application role.
DBPASSWORD postgres Yes, alongside DBUSER PostgreSQL password.
DBHOST localhost No PostgreSQL host.
DBPORT 5432 No PostgreSQL port.

Connection pooling

Pooling uses psycopg 3's psycopg_pool through Django's PostgreSQL backend. It is off by default, so upgrading does not silently change an existing deployment's connection behaviour. Turning it on also enables CONN_HEALTH_CHECKS; CONN_MAX_AGE is pinned to 0, because Django raises ImproperlyConfigured ("Pooling doesn't support persistent connections") if a pool is configured alongside persistent connections.

Variable Default Required Purpose
DB_POOL_ENABLED False No "true" (case-insensitive) puts a psycopg_pool.ConnectionPool in front of the default database and installs the RLS reset callback described below. Anything else leaves the connection behaviour unchanged.
DB_POOL_MIN_SIZE 2 No Connections the pool keeps open per process even when idle.
DB_POOL_MAX_SIZE 10 No Ceiling on concurrent connections per process. This is the limit that was missing when the API exhausted PostgreSQL: under ASGI every in-flight request otherwise takes its own connection with no upper bound.

Size this against max_connections, and remember the pool is per process, not per host. Peak usage is roughly DB_POOL_MAX_SIZE × (uvicorn workers + Celery prefork children), plus DB_POOL_MIN_SIZE held idle by each of those processes. docker-compose.yml runs celery -A crm worker with no --concurrency, so each worker forks one child per CPU. On an 8-core host with 3 uvicorn workers and the defaults, that is 10 × (3 + 8) = 110 connections at peak against PostgreSQL's default max_connections of 100.

Why the reset callback matters. RLS context lives in app.current_org, a session-scoped setting that outlives the transaction and the request. psycopg_pool does not clear session state when a connection is returned; it only rolls back an open transaction. A pool without common.rls.pool.reset_rls_context therefore hands the next borrower a connection still scoped to the previous tenant. Enabling DB_POOL_ENABLED wires that callback in automatically, and common/tests/test_pool_rls_isolation.py proves both that it clears the context and that it leaves the connection in the IDLE state psycopg_pool requires. Do not configure a pool by hand without it.

Email

Variable Default Required Purpose
EMAIL_BACKEND django.core.mail.backends.console.EmailBackend No Django email backend. The default prints outbound mail to stdout instead of sending it. Setting ENV_TYPE=prod overrides this unconditionally to django_ses.SESBackend, regardless of what this variable is set to. See Email and Celery.
DEFAULT_FROM_EMAIL noreply@localhost No From address on outbound mail.
ADMIN_EMAIL admin@localhost No Assigned to settings.ADMIN_EMAIL but not consumed anywhere else in settings.py itself. The one place it's read outside this module and create_default_admin (see below) is common/views/user_views.py, which returns it as admin_email in the user-list API response context. create_default_admin also reads ADMIN_EMAIL a second time, independently and with the same default, to name the bootstrap Django superuser account.
AWS_SES_REGION_NAME ap-south-1 No, unless ENV_TYPE=prod Only read when "django_ses" appears in EMAIL_BACKEND. True if you set EMAIL_BACKEND=django_ses.SESBackend directly, or unconditionally true once ENV_TYPE=prod forces that backend. Under ENV_TYPE=prod, server_settings.py requires this variable outright (os.environ[...], no default) before settings.py's own conditional read ever runs, so the default above is effectively unreachable in that mode.
AWS_SES_REGION_ENDPOINT email.<AWS_SES_REGION_NAME>.amazonaws.com No, unless ENV_TYPE=prod Same condition and the same ENV_TYPE=prod-makes-it-required caveat as AWS_SES_REGION_NAME above.

Celery and Redis

Variable Default Required Purpose
CELERY_BROKER_URL redis://localhost:6379/0 Yes, before anyone but you can reach the instance Redis URL Celery uses as its message broker.
CELERY_RESULT_BACKEND redis://localhost:6379/0 Yes, alongside CELERY_BROKER_URL Redis URL Celery uses to store task results.

Cache and rate limiting

Variable Default Required Purpose
CACHE_URL (empty, falls back to a per-process LocMemCache) Yes, if you run more than one worker and want rate limiting to be real Redis URL for Django's default cache, e.g. redis://localhost:6379/1. DRF throttling stores its counters here. Left unset, each Gunicorn worker keeps its own counters, so the effective limit is roughly the configured rate times the worker count and it resets on every restart.
WEBFORM_THROTTLE_IP 10/hour No Rate limit for public web form submissions, per client IP per form. The client IP comes from X-Forwarded-For, which a direct caller can forge, so treat this as the layer that stops ordinary flooding rather than a determined attacker.
WEBFORM_THROTTLE_GLOBAL 200/day No Rate limit for public web form submissions, per form across all clients. Header rotation cannot evade this one, so it is the real backstop. Raise it for a form on a high-traffic page.
HELP_CENTER_THROTTLE_IP 600/hour No Rate limit for the public help center API (/api/public/help/), per client IP. The web app forwards the visitor's address, so each reader and crawler gets its own bucket. Like the web form limit, the IP comes from X-Forwarded-For and can be forged by a direct caller; the pages it protects are public by the org's choice.
HELP_CENTER_THROTTLE_GLOBAL 10000/hour No Rate limit for the public help center API, per help center across all clients. Header rotation cannot evade this one, so it is the backstop behind HELP_CENTER_THROTTLE_IP. It is bucketed per help center, so traffic to one org's pages never limits another's. Raise it for a busy help center.

CORS and CSRF

Variable Default Required Purpose
CORS_ALLOW_ALL False No Sets Django's CORS_ORIGIN_ALLOW_ALL. Note the name doesn't match the Django setting it controls. "true" (case-insensitive) allows every origin; anything else leaves it off.
CORS_ALLOWED_ORIGINS http://localhost:5181,http://127.0.0.1:5181 Yes, once the frontend is served from a real domain Comma-separated list of allowed CORS origins; each entry is stripped of surrounding whitespace and empty entries are dropped.
CSRF_TRUSTED_ORIGINS (empty, no trusted origins) Yes, before anyone but you can reach the instance Comma-separated list, same stripping rule as CORS_ALLOWED_ORIGINS. Each entry needs a scheme, e.g. https://crm.example.com. A bare hostname is silently useless to Django's CSRF check.

URLs

Variable Default Required Purpose
DOMAIN_NAME http://localhost:8000 No. Nothing reads it any more Base URL of the backend. It no longer reaches a single emitted link. The four remaining reads (invoices/tasks.py:305,482 and invoices/api_views.py:276,436) pass it as the domain= kwarg of Celery tasks whose signatures keep that parameter only so a task queued before the change still unpacks after deploy, and whose docstrings say it is ignored. Every link now comes from FRONTEND_URL via common/links.py. It used to build the internal "assigned to you" notifications as well as the customer-facing invoice, estimate and CSAT links, and every one of those was broken: the builders wrote f"{protocol}://{domain}/..." around a value that already carried a scheme, so the emitted link had two, and the paths are frontend routes that do not exist on the backend host. Setting this variable changes nothing; it is kept so an existing .env does not have to be edited.
FRONTEND_URL http://localhost:5181 Yes, once self-hosted under a real domain Base URL of the SvelteKit frontend, and the single source for every link that leaves the system in an email: the welcome email and magic-link sign-in URL (common/tasks.py), the emailed invoice and estimate portal links, the CSAT survey link, and the "assigned to you" invoice link (all via common.links.frontend_url). The customer-facing ones go to people outside the organization, so leaving the localhost default in a real deployment emails dead links to customers. That is now refused rather than emailed: when ENV_TYPE is anything other than dev, crm/settings.py raises at import if this points at a loopback host (localhost, 127.0.0.1, 0.0.0.0, ::1) or is not an absolute http/https URL, so the deploy fails to start instead of failing silently on somebody else's inbox. Plain HTTP against a real host is allowed, for a deployment behind its own TLS terminator. A trailing slash is stripped, so https://app.example.com and https://app.example.com/ behave the same.

Google OAuth

Variable Default Required Purpose
GOOGLE_CLIENT_ID (empty) No, leaving it blank disables Google sign-in OAuth client ID sent to Google's token endpoint and used as the expected audience when verifying a mobile ID token. See Google OAuth.
GOOGLE_CLIENT_SECRET (empty) No, leaving it blank disables Google sign-in OAuth client secret, alongside GOOGLE_CLIENT_ID above.

Docker bootstrap (not read by crm/settings.py)

Variable Default Required Purpose
ADMIN_PASSWORD (empty string) No, but leaving it unset is a real security gap Read directly by the create_default_admin management command. An empty value doesn't mean "no password", the command falls back to the literal password admin and prints a warning. create_default_admin runs automatically on every Docker container start (docker/backend/entrypoint.sh, right after migrate), so an unset ADMIN_PASSWORD in a real deployment means the bootstrap Django superuser's password is the literal string admin until someone changes it. See Management commands.