Backups and upgrades¶
This page covers what actually needs backing up in a self-hosted BottleCRM instance, and what "upgrading" means for the Docker Compose setup versus a manual install.
What to back up¶
Two things hold state that nothing else can regenerate: the PostgreSQL database and the media
directory. Everything else: the application code, static assets (collectstatic output),
installed dependencies, the Redis broker/result store, is either checked into version control,
rebuilt from it, or disposable. Redis in particular is not a backup concern: it's the Celery broker
and result backend (CELERY_BROKER_URL/CELERY_RESULT_BACKEND), not a store of business data: an
in-flight task queue lost on restart means a retry, not lost records, since the records themselves
live in PostgreSQL.
Backing up PostgreSQL¶
Every org, every record, every RLS-protected row lives in the database configured by DBNAME/
DBUSER/DBHOST/DBPORT (see Environment variables). A plain
pg_dump is enough for a logical backup:
Under Docker Compose, run it against the db service:
docker compose exec db pg_dump -U postgres -d crm_db -F c -f /tmp/backup.dump
docker compose cp db:/tmp/backup.dump ./backup.dump
docker-compose.yml also declares postgres_data as a named volume mounted at
/var/lib/postgresql/data on the db service. This is the actual on-disk database, and is what
must survive a docker compose down (which removes containers but leaves named volumes intact) or a
rebuild. Only docker compose down -v, or removing the volume explicitly, deletes it. See
Docker.
Media files¶
MEDIA_ROOT depends on which settings module is active. With ENV_TYPE=dev (the default),
crm/settings.py sets MEDIA_ROOT to a local media/ directory under BASE_DIR, and uploaded
files (invoice attachments, case attachments, and similar) land on local disk. Under Docker Compose
this resolves to /app/media inside the backend container, which, because /app is the
bind-mounted ./backend directory, not a named volume. Means uploads land in backend/media/ on
the host automatically, but are not covered by the postgres_data volume's guarantees. Back up
backend/media/ (or your equivalent local media path in a manual install) alongside the database.
With ENV_TYPE=prod, backend/crm/server_settings.py switches DEFAULT_FILE_STORAGE to
storages.backends.s3boto3.S3Boto3Storage and media is written to the S3 bucket named by
AWS_BUCKET_NAME instead of local disk: in that configuration, back up the bucket using your normal
AWS backup practice (versioning, replication, or a separate backup job) rather than a filesystem
backup.
Upgrading¶
Under Docker Compose, docker/backend/entrypoint.sh runs manage.py migrate --noinput,
manage.py create_default_admin and manage.py collectstatic --noinput on every container
start, not only the first, so pulling new source and rebuilding is the entire upgrade procedure:
There is no separate migration step to remember; the schema and static assets are brought up to
date automatically on restart. Back up postgres_data and backend/media/, per the sections above,
before upgrading across any change you're not confident is backward compatible with your existing
data, nothing in this compose setup takes an automatic backup for you.
For a manual install (no Docker), the equivalent is: pull the new source, uv sync to update
dependencies, then run migrations and collectstatic yourself before restarting the process (see
Migrations below and Production deployment).
Migrations¶
manage.py migrate applies schema changes and, for the RLS-related migrations specifically,
re-applies the Row-Level Security policies described in
PostgreSQL and RLS. RLS is enabled and disabled by
migrations, not by any separate command. Those migrations run with atomic = False (the comment
next to it, repeated across every migration that calls get_enable_policy_sql, is explicit: "RLS
policy creation can't run inside an atomic block"), and get_enable_policy_sql() itself issues
DROP POLICY IF EXISTS before each CREATE POLICY, so re-running migrate after an interrupted run
is safe rather than something that fails on a duplicate policy.
This project's own CI enforces that no model change ships without a matching migration:
.github/workflows/tests.yml runs
as a dedicated step before the test suite. If you fork this project and add or change a model, run
manage.py makemigrations yourself and commit the result. The check names the problem directly;
without it you would find out indirectly, from a test or a request failing against a column the
database does not have.