Deploy Multi-Tenant Django with django-tenants
Deploy a schema-per-tenant Django SaaS with django-tenants: wildcard DNS and TLS, Nginx for subdomains, migrate_schemas in releases, tenant-aware caching, media, Celery, and pooling.
Problem statement
django-tenants gives each customer of a Django SaaS its own PostgreSQL schema, selected per request from the hostname (acme.example.com → schema acme). It keeps tenant data separated without running one database per customer.
Deploying it is different from a single-tenant Django app in ways that show up only in production: every new subdomain needs DNS and a certificate, migrate no longer migrates everything, migrations take longer with every tenant, caches and uploaded files can leak between tenants, Celery tasks run in the wrong schema, and connection poolers interact badly with per-request search_path changes. This guide covers each of those.
- Create a wildcard DNS record
*.example.compointing at your load balancer or server. - Obtain a wildcard certificate (Let's Encrypt requires the DNS-01 challenge for wildcards).
- Configure Nginx with
server_name example.com .example.com;and pass theHostheader through. - Set
ALLOWED_HOSTS = [".example.com"]; tenant middleware must come first inMIDDLEWARE. - Replace
migratein the deploy withmigrate_schemas --sharedfollowed bymigrate_schemas(optionally parallel). - Make caches, media paths, and Celery tasks tenant-aware.
- Use session pooling or direct connections unless you have verified transaction pooling with your setup.
Step-by-step solution
1. Settings recap
DATABASES = {
"default": {
"ENGINE": "django_tenants.postgresql_backend",
"NAME": "saas",
"USER": "saas",
"PASSWORD": os.environ["DATABASE_PASSWORD"],
"HOST": os.environ["DATABASE_HOST"],
"CONN_MAX_AGE": 60,
}
}
DATABASE_ROUTERS = ("django_tenants.routers.TenantSyncRouter",)
MIDDLEWARE = [
"django_tenants.middleware.main.TenantMainMiddleware",
"django.middleware.security.SecurityMiddleware",
# ...
]
TENANT_MODEL = "customers.Client"
TENANT_DOMAIN_MODEL = "customers.Domain"
ALLOWED_HOSTS = ["example.com", ".example.com"]
SHARED_APPS (in the public schema) and TENANT_APPS (in every tenant schema) define where each app's tables live. The tenant middleware must run before anything that touches the database, so it goes first.
2. Wildcard DNS and certificates
Create DNS records:
example.com. A 203.0.113.10
*.example.com. A 203.0.113.10
Wildcard certificates from Let's Encrypt need a DNS-01 challenge, which requires API access to your DNS provider through a Certbot plugin:
sudo apt install certbot python3-certbot-dns-cloudflare
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d example.com -d "*.example.com"
A wildcard covers one level only: acme.example.com yes, eu.acme.example.com no. Tenants that bring their own domain (app.acme.com) need individual certificates, usually issued on demand by a proxy such as Caddy, or by a managed load balancer.
3. Nginx for all subdomains
server {
listen 443 ssl;
server_name example.com .example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location /static/ {
alias /srv/saas/current/staticfiles/;
}
location / {
proxy_pass http://unix:/run/gunicorn/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Host $host is essential: the tenant is resolved from it. A proxy that rewrites the host breaks tenant routing. Unknown subdomains raise a 404 from the middleware unless you configure a fallback (SHOW_PUBLIC_IF_NO_TENANT_FOUND).
4. Migrations in the deploy
manage.py migrate is replaced by migrate_schemas:
python manage.py migrate_schemas --shared
python manage.py migrate_schemas --executor=multiprocessing
--sharedmigrates thepublicschema (shared apps) first.- The second command migrates every tenant schema. With the multiprocessing executor, tenants migrate in parallel, which matters once you have hundreds of them. Control concurrency with the
TENANT_MULTIPROCESSING_MAX_PROCESSESsetting so the database is not overwhelmed. - Migration time grows with tenant count. A migration that takes one second per tenant takes several minutes for a few hundred tenants; backward-compatible migrations let the old code keep serving during that window.
- If a migration fails partway, some tenants are migrated and some are not. Rerunning
migrate_schemascontinues from where each schema stopped; fix the failing migration rather than editing schemas by hand.
5. Tenant-aware caching
Without separation, cache.get("dashboard_stats") returns another tenant's data. django-tenants ships a key function that prefixes keys with the schema name:
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": os.environ["REDIS_URL"],
"KEY_FUNCTION": "django_tenants.cache.make_key",
"REVERSE_KEY_FUNCTION": "django_tenants.cache.reverse_key",
}
}
Sessions stored in the cache are then separated as well.
6. Tenant-aware media
Uploaded files from different tenants should not share one directory. django-tenants provides a storage class that adds the tenant to the path:
STORAGES = {
"default": {"BACKEND": "django_tenants.files.storage.TenantFileSystemStorage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
MULTITENANT_RELATIVE_MEDIA_ROOT = "%s"
With object storage, include the schema name in upload_to or a custom storage location so files are separated by prefix, and consider per-tenant access checks when serving private files.
7. Celery tasks
Tasks run outside a request, so there is no hostname and no tenant; they run in the public schema by default. The tenant-schemas-celery package passes the current schema with each task and switches to it in the worker. Alternatively, pass the schema name explicitly and wrap the task body:
from django_tenants.utils import schema_context
@app.task
def send_digest(schema_name, user_id):
with schema_context(schema_name):
...
Celery Beat schedules that should run for every tenant need a dispatcher task that iterates tenants and enqueues one task per schema.
8. Connection pooling
django-tenants switches schemas by setting search_path on the connection. With PgBouncer in transaction pooling mode, consecutive transactions may run on different server connections, so a schema set for one request can be missing or stale for the next. Use session pooling, direct connections with CONN_MAX_AGE, or thoroughly test transaction pooling with your django-tenants version before relying on it.
Verification check:
curl -sI https://acme.example.com/andhttps://globex.example.com/return each tenant's site.- A request to an unknown subdomain returns the expected 404 or public page.
python manage.py tenant_command showmigrations --schema=acmeshows no unapplied migrations for a sample tenant after deploy.- A cached value set by one tenant is not visible to another.
Explanation
Schema-per-tenant isolation depends on one fact: the database connection's search_path points at the right schema for the code that is running. The middleware sets it from the hostname on each request; everything else in this guide exists to preserve that fact in places without a request (tasks, commands), in shared infrastructure (caches, storage, poolers), and across releases (migrating every schema).
Edge cases / notes
Creating tenants
Creating a tenant creates a schema and runs all tenant migrations for it, which can take seconds. Do it in a background task, not in the signup request.
Backups and restores
A normal pg_dump includes all schemas. Restoring a single tenant is possible with pg_dump --schema=acme exports, which are worth testing before a customer asks for one.
When to automate this
Tenant onboarding (tenant row, domain row, schema creation, DNS for custom domains), per-schema migrations with progress reporting, and tenant-iterating scheduled tasks are all repetitive and error-prone by hand. Wrap each in a management command or task, and call them from your deploy and signup flows.
Next steps
- Review database connection settings in How to Connect Django to PostgreSQL in Production.
- Plan backward-compatible changes with Django Migration Strategy for Safe Deployments.
- Set up certificate automation with Configure HTTPS for Django with Let's Encrypt.
- If subdomains return 400 errors, see How to Fix DisallowedHost in Django Production.
FAQ
How do I run migrations with django-tenants?
Run migrate_schemas --shared for the public schema, then migrate_schemas for all tenants. The multiprocessing executor migrates tenants in parallel.
Why does Let's Encrypt refuse my wildcard certificate over HTTP?
Wildcard certificates can only be validated with the DNS-01 challenge. Use a Certbot DNS plugin for your DNS provider.
Does django-tenants work with PgBouncer?
Session pooling works. Transaction pooling can break schema switching because search_path is connection state; test carefully before using it.
How do I stop cache data leaking between tenants?
Use django_tenants.cache.make_key as the cache KEY_FUNCTION so keys are prefixed with the tenant schema.