Docker¶
docker-compose.yml, at the repository root, runs the full stack: database, cache/broker,
API, background workers and frontend, as six services from one file. This page documents that
file service by service. For the fastest path to a running instance with demo data loaded, see
Docker quick start; this page instead covers what to
configure, how data is persisted, and how to upgrade an instance you intend to keep running.
This compose file is a development setup, not a hardened production one. Its backend
service bind-mounts the repository (./backend:/app) and its entrypoint
(docker/backend/entrypoint.sh) ends by running python manage.py runserver 0.0.0.0:8000, the
Django development server, not Gunicorn. .env.docker also ships DEBUG=True. Running it as-is
gets every service talking to each other correctly; it does not by itself satisfy the
requirements in Production deployment.
The services¶
docker-compose.yml defines:
| Service | Image / build | Host port | Purpose |
|---|---|---|---|
db |
postgres:16-alpine |
5432 |
The PostgreSQL database, crm_db. Row-Level Security policies applied by Django migrations rely on this being real PostgreSQL. See PostgreSQL and RLS. |
redis |
redis:7-alpine |
6379 |
Celery broker and result backend (CELERY_BROKER_URL / CELERY_RESULT_BACKEND both point here). |
backend |
built from the repo-root Dockerfile |
8000 |
The Django API, served via manage.py runserver in this compose file (see the warning above). |
celery-worker |
same image as backend |
, (no published port) | Runs celery -A crm worker --loglevel=info; executes background tasks such as outbound email and RLS-context-aware jobs in backend/common/tasks.py. |
celery-beat |
same image as backend |
, (no published port) | Runs celery -A crm beat --loglevel=info; fires the periodic tasks registered in backend/crm/celery.py (recurring invoices, overdue/expiry checks, SLA scanning, cleanup jobs) on their schedules. |
frontend |
built from frontend/Dockerfile |
5181 |
The SvelteKit app, served via pnpm dev --host 0.0.0.0, also a dev server, not a production build. |
backend, celery-worker and celery-beat each declare depends_on: db (service_healthy), redis
(service_healthy), using the pg_isready and redis-cli ping healthchecks defined on those two
services, so they wait for both to actually accept connections rather than just for their
containers to start.
Configuration¶
Every service loads two env files, in order: .env.docker (checked into the repository, working
defaults) and then .env.docker.local (optional, gitignored, required: false, create it next
to .env.docker to override specific values without touching the tracked file; values there win).
The variables .env.docker sets: SECRET_KEY (an insecure placeholder. See
Production deployment before this instance is anything but local),
DEBUG=True, ENV_TYPE=dev, ALLOWED_HOSTS=localhost,127.0.0.1,backend,
DOMAIN_NAME (http://localhost:8000), the DBNAME/DBUSER/DBPASSWORD/DBHOST/
DBPORT the backend and Celery containers use to reach db, POSTGRES_DB/POSTGRES_USER/
POSTGRES_PASSWORD (used only by the db service itself, to create the database and its own
superuser role. See PostgreSQL and RLS for why the app never connects
as this role), CELERY_BROKER_URL/CELERY_RESULT_BACKEND (both pointing at redis),
DEFAULT_FROM_EMAIL/ADMIN_EMAIL/ADMIN_PASSWORD, CORS_ALLOW_ALL=True,
GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET (blank. Google sign-in stays disabled until both
are set), and PUBLIC_DJANGO_API_URL=http://backend:8000, the compose service name the
frontend's server-side code uses to reach the API.
Running it¶
From the repository root (the directory containing docker-compose.yml and Dockerfile):
This requires the Compose v2 plugin (the docker compose subcommand, not the standalone
docker-compose binary). On first boot, docker/postgres/init-rls-user.sql runs automatically
via PostgreSQL's /docker-entrypoint-initdb.d/ mechanism and creates the non-superuser crm_user
role that backend actually connects as. docker/backend/entrypoint.sh then waits for
PostgreSQL to accept connections, runs manage.py migrate --noinput, runs
manage.py create_default_admin, runs manage.py collectstatic --noinput, and finally starts the
dev server. Every one of those steps runs again on every container restart, not just the first.
Run any one-off management command inside the running backend container with docker compose
exec, for example:
docker compose exec backend python manage.py migrate
docker compose exec backend python manage.py manage_rls --status
Persisting data¶
docker-compose.yml declares two named volumes:
| Volume | Mounted at | Holds |
|---|---|---|
postgres_data |
/var/lib/postgresql/data on db |
The actual database: every org, record, and RLS-protected row. This is the volume that must survive a rebuild. |
frontend_node_modules |
/app/node_modules on frontend |
Installed frontend dependencies, a build cache, not user data. Safe to delete; pnpm install recreates it. |
Everything else is a bind mount of source code from the host, not a Docker-managed volume:
./backend:/app on backend/celery-worker/celery-beat, and ./frontend:/app on frontend.
One consequence worth knowing: with ENV_TYPE=dev (the shipped default), Django's MEDIA_ROOT is
BASE_DIR/media, which resolves inside the container to /app/media, and because /app is the
bind-mounted ./backend directory, uploaded files (invoice attachments, case attachments, and so
on) land in backend/media/ on the host automatically. They persist across container rebuilds for
the same reason the source code does, without needing a dedicated named volume, but they are
not covered by a postgres_data-style volume, so back up backend/media/ alongside the
database if you rely on uploaded files.
docker compose down removes containers and the default network but leaves named volumes intact;
only docker compose down -v, or removing postgres_data/frontend_node_modules explicitly,
deletes them.
Upgrading¶
To move to a newer version of the application: pull the new source, then rebuild and restart:
Because entrypoint.sh runs manage.py migrate --noinput on every container start (not only the
first), rebuilding and restarting is sufficient to bring the schema up to date. There is no
separate migration step to remember. collectstatic also reruns on every start, so updated static
assets are picked up automatically.
Back up the postgres_data volume (and backend/media/, per the note above) before upgrading
across any change you're not certain is backward compatible with your existing data, nothing in
this compose setup takes an automatic backup for you.