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.

Schema-per-tenant requestacme.example.comwildcard DNS, TLSNginxwildcard server_nameTenant middlewareset search_pathSchema acmetenant tablesThe hostname selects the tenant, so DNS, TLS, and ALLOWED_HOSTS must accept every subdomain.
  1. Create a wildcard DNS record *.example.com pointing at your load balancer or server.
  2. Obtain a wildcard certificate (Let's Encrypt requires the DNS-01 challenge for wildcards).
  3. Configure Nginx with server_name example.com .example.com; and pass the Host header through.
  4. Set ALLOWED_HOSTS = [".example.com"]; tenant middleware must come first in MIDDLEWARE.
  5. Replace migrate in the deploy with migrate_schemas --shared followed by migrate_schemas (optionally parallel).
  6. Make caches, media paths, and Celery tasks tenant-aware.
  7. 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
certificates for every tenant subdomainOne wildcard *.example.com✓new tenants work immediatelyneeds the DNS-01 challengeone level only: not a.b.example.comCertificate per tenantrequired for custom tenant domains✓issued on demand, HTTP-01 works✕rate limits with many tenantsMost SaaS setups use a wildcard, plus on-demand certificates for custom domainsdjango-deployment.com
DNS-01 needs an API token for your DNS provider on the server that renews.

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
schema migrations, shared first1Back up the databaseall schemas at once2migrate_schemas --sharedpublic schema: tenants, domains3migrate_schemas --tenantevery tenant schema in turn4Check for failed tenantsthe command stops at errors5Restart the appnew code, all schemas readyTenant migration time grows with the number of tenantsdjango-deployment.com
With hundreds of tenants, run tenant migrations in parallel or before the switch-over.
  • --shared migrates the public schema (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_PROCESSES setting 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_schemas continues 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",
    }
}
the schema is not the only boundaryLeak without careFixCache keyssame key for every tenanttenant-aware KEY_FUNCTIONMedia filesshared upload pathsschema name in the pathCelery taskstask runs in public schemapass and set the schemaPooled connectionsstale search_pathtransaction pooling careSchemas isolate tables; everything outside the database needs its own prefixdjango-deployment.com
Each of these fails silently: one tenant simply sees another tenant’s data.

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/ and https://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=acme shows 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

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.

Keep going