Docker
The repository ships with a production-style docker-compose.yml that brings up everything you need: Postgres, Redis, the Django backend, a Celery worker, and the SvelteKit frontend.
Minimum requirements
- CPU: 2 vCPU (4 for production)
- RAM: 2 GB (4 GB recommended)
- Disk: 10 GB
- OS: Anything that runs Docker: Linux, macOS, Windows with WSL2
Topology
┌────────────┐ ┌────────────┐ ┌─────────┐
│ frontend │ → │ backend │ → │ db │
│ (sveltekit│ │ (django) │ │ (psql) │
│ :5173) │ │ :8000 │ └─────────┘
└────────────┘ └────────────┘ │
│ │
↓ │
┌─────────┐ │
│ redis │ ← worker ─┘
└─────────┘ (celery)
The frontend talks to the backend over HTTP. The backend uses Redis as the Celery broker and result store. Both backend and worker connect to Postgres through a non-superuser role so row-level security is enforced.
Running it
git clone https://github.com/MicroPyramid/Django-CRM.git
cd Django-CRM
# .env.docker is committed with dev defaults. Edit it, or add a gitignored
# .env.docker.local alongside it: values there win.
$EDITOR .env.docker
docker compose up --build -d
docker compose exec backend python manage.py migrate
docker compose exec backend python manage.py seed_data # optional
The frontend is now at http://localhost:5173, the API at http://localhost:8000/api/.
Production deployment
For production, run the stack behind a reverse proxy (Caddy, nginx, Traefik) that terminates TLS, and put the backend in DEBUG=False mode.
Key changes from the dev compose file:
- Pin image tags rather than building from source.
- Mount
/app/mediaand the Postgres data dir on persistent volumes. - Set
ALLOWED_HOSTSandCORS_ALLOWED_ORIGINSto your real domain. - Rotate
DJANGO_SECRET_KEYandJWT_SIGNING_KEYto fresh values. - Run
python manage.py manage_rls --statusto confirm RLS policies are present. - Redact public-link tokens from the proxy's access log (below).
Public-link tokens in access logs
The task calendar feed, the invoice and estimate portal links and the satisfaction survey link carry their credential in the URL path: whoever holds the URL can open it without signing in. From django-crm 1.13.0 the backend writes [Filtered] in place of the token in its own logs, including the uvicorn and gunicorn access logs. Your reverse proxy logs every path too, and the backend cannot reach that log. With nginx, add this to the http {} context:
map $request_uri $bottlecrm_log_uri {
"~^((?:/api/public/(?:calendar|csat|invoice|estimate)|/portal/(?:invoice|estimate)|/csat)/)[^/?]+(.*)$" "$1[Filtered]$2";
default $request_uri;
}
map $http_referer $bottlecrm_log_referer {
"~^((?:https?://[^/]+)?(?:/api/public/(?:calendar|csat|invoice|estimate)|/portal/(?:invoice|estimate)|/csat)/)[^/?]+(.*)$" "$1[Filtered]$2";
default $http_referer;
}
log_format bottlecrm_redacted '$remote_addr - $remote_user [$time_local] '
'"$request_method $bottlecrm_log_uri $server_protocol" '
'$status $body_bytes_sent "$bottlecrm_log_referer" "$http_user_agent"';
Then use it in the server {} blocks for both the API and the frontend, keeping each block's log path, for example access_log /var/log/nginx/access.log bottlecrm_redacted;, and run nginx -t before reloading. The Referer needs the same treatment because the portal pages are same-origin, so every asset they load carries the page URL. nginx's error_log cannot be reformatted, so an upstream error on one of these paths still records it there, and log lines written before the change still hold working tokens.
Backups
There are two things to back up:
- The Postgres database:
pg_dump --format=customis enough. Take it nightly and offsite-replicate it. - Uploaded media: anything under the backend's
media/directory (attachments on cases, contact avatars, etc).
Restore is a vanilla pg_restore plus copying the media directory back.