Environment variables
All runtime configuration is read from environment variables. The canonical reference is
backend/.env.example in the repository. Copy it and edit:
cd backend
cp .env.example .env
Required
| Variable | Default | Description |
|---|---|---|
SECRET_KEY |
dev placeholder | Django's signing key, and it also signs every JWT. Startup fails outside dev if it is left at the placeholder. It must be at least 32 bytes: HS256 needs a key as long as its hash, and a shorter one only produces a warning nobody reads. Changing it signs everybody out. |
ENV_TYPE |
dev |
dev or prod. Controls where media is written and which security checks are enforced at startup. |
DEBUG |
True |
Set to False in production. |
ALLOWED_HOSTS |
localhost,127.0.0.1 |
Comma-separated. |
FRONTEND_URL |
http://localhost:5173 |
The web app's base URL, and the one setting that carries every link this system emails: magic-link sign-in, the customer's invoice and estimate portal links, the CSAT survey, and internal assignment notices. Outside dev a loopback value is refused at startup, because left at the default it mails your customers a link to their own machine. |
Database
There is no DATABASE_URL. The connection is assembled from five separate variables:
| Variable | Default |
|---|---|
DBNAME |
crm_db |
DBUSER |
postgres |
DBPASSWORD |
postgres |
DBHOST |
localhost |
DBPORT |
5432 |
PostgreSQL only, and the application user must not be a Postgres superuser. Superusers bypass row-level security, which is what isolates one organisation's rows from another's. See Postgres + RLS.
Connection pooling
| Variable | Default | Description |
|---|---|---|
DB_POOL_ENABLED |
False |
Turn on psycopg's connection pool. |
DB_POOL_MIN_SIZE |
2 |
|
DB_POOL_MAX_SIZE |
10 |
CONN_MAX_AGE is pinned to 0 because Django refuses a non-zero value alongside a pool.
Celery
| Variable | Default |
|---|---|
CELERY_BROKER_URL |
redis://localhost:6379/0 |
CELERY_RESULT_BACKEND |
redis://localhost:6379/0 |
Not optional if you want the scheduled work: recurring invoices, overdue marking, payment reminders, expired estimates, SLA breach scanning and stale-timer cleanup are all periodic jobs. Without a worker and a beat process, those features are silently inert.
| Variable | Default | Description |
|---|---|---|
EMAIL_BACKEND |
console backend | Use django_ses.SESBackend for AWS SES. |
DEFAULT_FROM_EMAIL |
noreply@localhost |
|
ADMIN_EMAIL |
admin@localhost |
|
AWS_SES_REGION_NAME |
ap-south-1 |
Read only when the backend is SES. |
AWS_SES_REGION_ENDPOINT |
derived from the region |
SES falls back to IAM role credentials if AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are unset.
For plain SMTP, set EMAIL_BACKEND to Django's SMTP backend and add Django's own EMAIL_HOST
family; the project does not define those itself.
Google OAuth
| Variable | Description |
|---|---|
GOOGLE_CLIENT_ID |
Leave blank to disable Google sign-in. |
GOOGLE_CLIENT_SECRET |
Magic-link sign-in works without either, so OAuth is genuinely optional.
CORS, CSRF and proxies
| Variable | Default | Description |
|---|---|---|
CORS_ALLOWED_ORIGINS |
dev origins | Comma-separated. |
CORS_ALLOW_ALL |
False |
Do not enable in production. |
CSRF_TRUSTED_ORIGINS |
dev origin | |
TRUST_PROXY_SSL_HEADER |
False |
Set to True only when TLS terminates at a proxy that is the only way in. It makes Django trust X-Forwarded-Proto, which is what lets HSTS emit a header at all. On direct HTTP it becomes a value any caller can forge. |
DOMAIN_NAME |
http://localhost:8000 |
The API's own public origin, for example https://api.example.com. From django-crm 1.13.0 the task calendar feed URL and the web form embed snippets are built from it, and outside dev the backend refuses to start when it is a localhost or non-absolute URL. |
NUM_PROXIES |
unset | From django-crm 1.11.0. How many proxies in front of Django append to X-Forwarded-For (nginx with $proxy_add_x_forwarded_for is one). It decides the client IP the app records (audit log, web form submissions, estimate acceptance, sign-in tokens) and the per-IP throttles. Set it to the proxy count only when REMOTE_ADDR is the proxy itself, such as gunicorn behind nginx (1); unset, every visitor then shares one throttle bucket. Leave it unset under uvicorn --proxy-headers, which already resolves the client, or the proxy is counted twice. Setting it higher than the real number of proxies lets callers choose their own IP. |
RELAY_SECRET |
unset | From django-crm 1.12.0. Shared secret with the SvelteKit app, which fetches the public help center and estimate portal on the visitor's behalf. With the same value set on both, the app sends the visitor's address signed with it and the API records and throttles each visitor separately; without it they all count as the app's server. Headers without the secret are ignored, so a caller cannot choose its address. At least 32 characters or the API refuses to start. Generate with python -c "import secrets; print(secrets.token_urlsafe(48))". |
Frontend
The SvelteKit app reads these variables:
| Variable | Description |
|---|---|
PUBLIC_DJANGO_API_URL |
Base URL of the Django backend. The app's server calls the API here, so it can be an internal address such as http://backend:8000. The URLs the API hands back for use elsewhere (the task calendar feed URL, web form embed snippets) come from the backend's DOMAIN_NAME, not from this. |
PUBLIC_SENTRY_DSN |
Optional error reporting. |
RELAY_SECRET |
Optional. The same value as the API's RELAY_SECRET; see above. Private: never prefix it with PUBLIC_. |
ADDRESS_HEADER, XFF_DEPTH |
Read by adapter-node itself. Behind a reverse proxy, set ADDRESS_HEADER=X-Forwarded-For and XFF_DEPTH=1 (one proxy that appends the visitor's address) so the app knows who the visitor is. Without them every visitor appears as the proxy, and RELAY_SECRET has nothing real to send. |
HOST |
Read by adapter-node itself; defaults to 0.0.0.0. Behind a reverse proxy, set HOST=127.0.0.1 so only the proxy can reach the app. Reachable directly, a caller writes its own X-Forwarded-For and the app signs it as the visitor. |
Anything prefixed PUBLIC_ is shipped to the browser. Never put a secret there.
Media and attachments
Files are written to the local filesystem under media/, always. There is no S3 or object-storage
backend: the project configures no STORAGES or DEFAULT_FILE_STORAGE, so AWS_BUCKET_NAME in
the example file is currently inert for uploads and the AWS keys matter only for SES. If you need
object storage, that is a change to the Django settings rather than an environment variable.
A single attachment is capped at 25 MB, and CSV imports and lead uploads at 5 MB.
Corrections
This page previously documented DJANGO_SECRET_KEY, JWT_SIGNING_KEY, DATABASE_URL, REDIS_URL,
EMAIL_HOST, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_PORT, GOOGLE_REDIRECT_URI,
AWS_STORAGE_BUCKET_NAME, AWS_S3_ENDPOINT_URL, PUBLIC_API_URL and PUBLIC_GOOGLE_CLIENT_ID, and
pointed at a .env.docker.example file. None of those names is read by crm/settings.py and that
file does not exist, so anyone configuring a deployment from this page could not have started the
application. Kept as a note rather than deleted, so somebody debugging a .env written from the old
version can see what happened.