Skip to main content
A multitenant application serves several customers (tenants) from one deployment while keeping their data apart. With django-tenants, each tenant gets its own PostgreSQL schema and its own subdomain, such as acme.shop.example.com, and a public schema holds what tenants share. On Sevalla, a wildcard custom domain sends every subdomain to the application, and a job migrates every schema before each deployment.

At a glance

This guide changes the application from Deploy a Django application:

Environment variables

The deploy guide lists the other variables.

Before you begin

  • A Django project deployed on Sevalla with a PostgreSQL database, as in Deploy Django. Use a database that holds no data you need yet: django-tenants expects to manage every schema from its first migration.
  • A domain whose DNS you control, such as shop.example.com.
  • The web process on a size larger than H1: custom domains aren’t available on H1. See Scaling.

Install django-tenants

Install django-tenants and update requirements.txt:
django-tenants 4.0 supports Django 5.2, 6.0 and 6.1, on PostgreSQL only.

Add the tenant models

Create an app for the tenants and their domains, and an app with the tenants’ own features:
customers/models.py

Configure the project

In storefront/settings.py, replace INSTALLED_APPS with the apps every tenant shares and the apps each tenant gets in its own schema, and name the tenant models:
storefront/settings.py
Put TenantMainMiddleware first in MIDDLEWARE:
storefront/settings.py
Replace ROOT_URLCONF with the tenants’ URLs and the main site’s URLs on the public schema:
storefront/settings.py
Replace DATABASES with django-tenants’ PostgreSQL backend and its router:
storefront/settings.py
  • TenantMainMiddleware comes before Django’s own middleware: it picks the tenant, and so the schema, from the request’s host. If you added the health check middleware, keep it in front of TenantMainMiddleware, because the probes’ host matches no tenant.
  • django.contrib.auth and django.contrib.sessions are both shared here. Keep them together, in SHARED_APPS or in TENANT_APPS: a session points at a user, so both tables must be in the same schema.
  • DB_URL comes from the database’s internal connection, which makes it available during the build too. The settings no longer fall back to SQLite, so for local development, add a local PostgreSQL database to .env, for example:
    .env
If the application builds from your own Dockerfile, its build doesn’t get DB_URL, and collectstatic stops with ImproperlyConfigured: Set the DB_URL environment variable. Give it a placeholder next to the placeholder SECRET_KEY. collectstatic loads the settings but doesn’t connect to the database:
Dockerfile

Add the views and URLs

shop/views.py
storefront/urls.py
storefront/urls_public.py
Create the migrations, then commit and push the changes, requirements.txt included:
customers gets its first migration. shop has no models yet, so Django reports No changes detected in app 'shop'. Commit the migration: without it, the Migrate job logs Your models in app(s): 'customers' have changes that are not yet reflected in a migration and fails with django.db.utils.ProgrammingError: relation "customers_client" does not exist.

Prepare the application on Sevalla

The Migrate job from the deploy guide needs no change: django-tenants replaces Django’s migrate command with its migrate_schemas, so python manage.py migrate --noinput migrates the public schema with the shared apps and every tenant’s schema with the tenant apps. Its output on the Logs tab starts each line with the schema, such as [standard:public].
1

Add the wildcard domain

In the sidebar, click Applications, then click the application. Click the Domains tab, then click Add custom domain. Enter shop.example.com as the Domain name, select Wildcard so that every subdomain such as acme.shop.example.com reaches the application too, and click Add custom domain. Add the DNS records that Sevalla shows at your DNS provider, including the pointing record for *.shop.example.com, and wait until the domain’s status is Active. See Wildcard domains.
2

Allow the subdomains

Click the Environment variables tab. Update ALLOWED_HOSTS to storefront-x7k2m.sevalla.app,.shop.example.com, with your application’s sevalla.app domain. The leading dot matches the domain and every subdomain.
3

Deploy

Click Deploy in the undeployed changes bar at the bottom of the page.
The Add custom domain sheet with a domain name field and a Wildcard optionThe Add custom domain sheet with a domain name field and a Wildcard option

Create the tenants

django-tenants serves a request only when its host belongs to a tenant, so create the public tenant for the main domain first. When the deployment’s status is Deployed, click the Web terminal tab, and in the shell that opens in the Web process, open a Django shell:
Saving acme creates the acme schema and runs the tenant apps’ migrations in it. Create every later tenant the same way, or from your own sign-up view. The application’s sevalla.app domain matches no tenant, so it returns 404. To show the public site there too, set SHOW_PUBLIC_IF_NO_TENANT_FOUND = True in storefront/settings.py.

Verify

  1. Open https://shop.example.com: the page reads Shop platform.
  2. Open https://acme.shop.example.com: the page reads Welcome to Acme Inc.
From a shell:

Deploy with the API

This sequence prepares the application from Deploy with the API and continues in its shell, with API, APP_ID, WEB_ID and APP_HOST set. Push the code changes from Configure the project and Add the views and URLs first. Each step names the CLI command and the Sevalla MCP server tool that do the same.
1

Look up the process size

List process resource types (listProcessResourceTypesV3). Save S1:
CLI: sevalla resources process-types. MCP: get_platform_options.
2

Size the web process

Update process (updateProcessV3) moves the web process to S1, which can have custom domains:
It prints s1. CLI: sevalla apps processes update "$WEB_ID" --app-id "$APP_ID" --resource-type "$S1_ID". MCP: scale_process with size_id and apply_now: false.
3

Add the wildcard domain

Add application domain (addApplicationDomainV3) with is_wildcard:
It prints VERIFICATION. CLI: sevalla apps domains add "$APP_ID" --name shop.example.com, which has no wildcard option. MCP: add_domain with wildcard: true.
4

Read the DNS records

List application domains (listApplicationDomainsV3) returns the records to add at your DNS provider a few seconds after the domain is added; until then, dns_records is null:
verification holds a TXT record for _cf-custom-hostname.shop.example.com, and origin holds the records that point shop.example.com and *.shop.example.com at Sevalla. The status changes from VERIFICATION to POINTING once Sevalla has verified the domain, and to ACTIVE once the domain points at Sevalla. CLI: sevalla apps domains list --app-id "$APP_ID". MCP: get_application.
5

Find ALLOWED_HOSTS

List environment variables (listEnvironmentVariablesV3) returns each variable’s ID:
CLI: sevalla apps env-vars list --app-id "$APP_ID". MCP: list_environment_variables.
6

Allow the subdomains

Update environment variable (updateEnvironmentVariableV3):
CLI: sevalla apps env-vars update "$HOSTS_ID" --app-id "$APP_ID" --value "$APP_HOST,.shop.example.com". MCP: set_environment_variables, which updates a variable by its key.
7

Deploy

Trigger deployment (triggerDeploymentV3). With Auto-deploy on, the push already started a deployment; this one queues behind it:
CLI: sevalla apps deployments trigger "$APP_ID". MCP: trigger_deployment.
8

Wait for the deployment

Get deployment (getDeploymentV3) reports the status, which ends as success, failed, cancelled or skipped. The loop keeps polling through a failed request, for up to 30 minutes:
CLI: sevalla apps deployments get "$DEPLOYMENT_ID" --app-id "$APP_ID". MCP: get_deployment.
9

Create the tenants

Run a command in a process (execProcessCommandV3) runs the code from Create the tenants in the web process. Creating a tenant’s schema takes a few seconds, so allow up to 60:
It prints 0. CLI: sevalla apps processes exec "$WEB_ID" --app-id "$APP_ID" --timeout 60 -- python manage.py shell -v 0 -c "<the same code>". MCP: sevalla_api_update with execProcessCommandV3 and the same body.
When the domain’s status is ACTIVE, run the checks in Verify.