Building Multi Tenant Applications with Django¶

Introduction to multi tenant applications¶
What are multi tenant apps?¶
Multi tenant applications allow you to serve multiple customers with one install of the application. Each customer has their data completely isolated in such an architecture. Each customer is called a tenant.
Most modern Software as a Service applications are multi tenant. Whether it is Salesforce, Freshbooks, Zoho or Wordpress, most modern cloud based applications are delivered with a multi-tenant architecture.
The structure of this book¶
In this book we will take a single tenant application and re-architect it to be a multi tenant application. We will use a slightly modified Django polls app as our base.
There are multiple approaches for multi tenancy. We will look at the four most common ones.
The various approached to multi tenancy¶
- Shared database with shared schema
- Shared database with isolated schema
- Isolated database with a shared app server
- Completely isolated tenants using Docker
Completely isolated tenants using Docker¶
A new set of docker containers are launched for each tenant. Every tenant’s data is in a separate database (which may or may not be running in container). A set of containers identifies the tenant.
In the next four chapters, we will look at each architecture in turn. Let’s get started.
Completely isolated tenants using Docker¶
Until this chapter we have separated the tenant data, but the app server has been common between tenants. In this chapter, we will complete the separation using Docker, each tenant app code runs it own container and the tenant.
Tools we will use¶
- Docker to build the app code image and run the containers
- Docker-compose to define and run the containers for each tenant
- Nginx to route the requests to correct tenant container
- A separate Postgres database (Running inside a docker container) for each tenant
- A separate app server (Running inside a docker container) for each tenant
Building a docker image from our app code¶
As the first step we need to convert our app code to Docker image. Create a file named Dockerfile
, and add this code.
FROM python:3
ENV PYTHONUNBUFFERED 1
RUN mkdir /code
WORKDIR /code
# Install requirements
ADD requirements.txt /code/
RUN pip install -r requirements.txt
ADD . /code/
# We will specify the CMD in docker-compose.yaml
With this, run docker build . -t agiliq/multi-tenant-demo
, to create an image and tag it as agiliq/multi-tenant-demo
.
Using docker-compose to run multi container, multi-tenant apps¶
As in our previous chapters, we will have two tenants thor
and potter
at urls potter.polls.local
and thor.polls.local
.
The architecture looks something like this:
+---------------------------+ +---------------------+
| | | |
| | | |
+---->| Thor App Server +------------> Thor DB |
| | | | |
+----------------------------+ | | | | |
| | | +---------------------------+ +---------------------+
| | |
| | |
| +-------+
| Nginx |
| | +---------------------------+ +----------------------+
| +------+ | | | |
| | | | | | |
| | | | Potter App Server +-----------> Potter DB |
+----------------------------+ | | | | |
+----->| | | |
+---------------------------+ +----------------------+
The containers we will be running are
- One nginx container
- 2 App servers, one for each tenant
- 2 DB servers, one for each tenant
- Transient containers to run
manage.py migrate
The final docker-compose.yaml¶
With our architecture decided, our docker-compose.yaml looks like this
version: '3'
services:
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
ports:
- "8080:80"
depends_on:
- thor_web
- potter_web
# Thor
thor_db:
image: postgres
environment:
- POSTGRES_PASSWORD=thor
- POSTGRES_USER=thor
- POSTGRES_DB=thor
thor_web:
image: agiliq/multi-tenant-demo
command: python3 manage.py runserver 0.0.0.0:8000
volumes:
- .:/code
depends_on:
- thor_db
environment:
- DATABASE_URL=postgres://thor:thor@thor_db/thor
thor_migration:
image: agiliq/multi-tenant-demo
command: python3 manage.py migrate
volumes:
- .:/code
depends_on:
- thor_db
environment:
- DATABASE_URL=postgres://thor:thor@thor_db/thor
# Potter
potter_db:
image: postgres
environment:
- POSTGRES_PASSWORD=potter
- POSTGRES_USER=potter
- POSTGRES_DB=potter
potter_web:
image: agiliq/multi-tenant-demo
command: python3 manage.py runserver 0.0.0.0:8000
volumes:
- .:/code
depends_on:
- potter_db
environment:
- DATABASE_URL=postgres://potter:potter@potter_db/potter
potter_migration:
image: agiliq/multi-tenant-demo
command: python3 manage.py migrate
volumes:
- .:/code
depends_on:
- thor_db
environment:
- DATABASE_URL=postgres://potter:potter@potter_db/potter
Let’s look at each of the components in detail.
Nginx¶
The nginx
config in our docker-compose.yaml
looks like this,
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
ports:
- "8080:80"
depends_on:
- thor_web
- potter_web
And nginx.conf
look like this
events {
worker_connections 1024;
}
http {
server {
server_name potter.polls.local;
location / {
proxy_pass http://potter_web:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
server {
server_name thor.polls.local;
location / {
proxy_pass http://thor_web:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}
In our nginx config, we are doing a proxypass to appropriate container,
using proxy_pass http://potter_web:8000;
, based on the host header.
We also need to set the Host
header, so Django can enforce its ALLOWED_HOSTS
.
DB and the App containers¶
Let’s look at the app containers we have launched for thor
. We will launch similar containers for tenants.
thor_db:
image: postgres
environment:
- POSTGRES_PASSWORD=thor
- POSTGRES_USER=thor
- POSTGRES_DB=thor
thor_web:
image: agiliq/multi-tenant-demo
command: python3 manage.py runserver 0.0.0.0:8000
volumes:
- .:/code
depends_on:
- thor_db
environment:
- DATABASE_URL=postgres://thor:thor@thor_db/thor
We are launching a standard postgres container with customized DB name and credentials. We are then running our Django code and passing the
credentials as DB name to he container using DATABASE_URL
environment variable. Our app set the db connection using dj_database_url.config()
which reads from DATABASE_URL
.
Running migrations and creating a superuser¶
We want to run our migrations for each DB as part of the deployment process, we will add container which does this.
thor_migration:
image: agiliq/multi-tenant-demo
command: python3 manage.py migrate
volumes:
- .:/code
depends_on:
- thor_db
environment:
- DATABASE_URL=postgres://thor:thor@thor_db/thor
This container will terminate as soon as migrations are done.
We also need to create a superuser. You can do this by docker exec
ing to the running app containers.
Do this docker exec -it <containet_name> bash. (You can get the container name by running docker ps
). Now you have a bash shell inside the container. Create your superuser in the usual way using manage.py createsuperuser
.
You can now access the thor tenant as thor.polls.local:8080
and potter at potter.polls.local:8080
. After adding a Poll
, my tenant looks like this.

The code for this chapter is available at https://github.com/agiliq/building-multi-tenant-applications-with-django/tree/master/isolated-docker
Tying it all together¶
Launching new tenants¶
In the previous chapters, we have worked with a hardcoded list, of two tenants, thor
and potter
. Our code looked like this
code-block:: python
- def get_tenants_map():
- return {“thor.polls.local”: “thor”, “poter.polls.local”: “potter”}
In a real scenario, you will need to launch tenants, so the list of tenants can’t be part of the code. To be able to launch new tenants, we will create a Tenant
model.
code-block:: python
- class Tenant(models.Model):
- name = models.CharField(max_length=100) schema_name = models.CharField(max_length=100) subdomain = models.CharField(max_length=1000, unique=True)
And your get_tenants_map
will change to:
code-block:: python
- def get_tenants_map():
- return dict(Tenant.objects.values_list(“subdomain”, “schema_name”))
You would need to make similar changes for a multi DB setup, or orchestrate launching new containers and updating nginx config for multi container setup.
A comparison of trade-offs of various methods¶
Until now, we had looked at four different ways of doing multi tenancy, each with some set of trade-offs.
Depending upon how many tenants you have, how many new tenants you need to launch, and your customization requirements, one of the four architectures will suit you.
Method | Isolation | Time to launch new tenants | Django DB Compatibility |
---|---|---|---|
Shared DB and Schema | Low | Low | High (Supported in all DBs) |
Isolated Schema | Medium | Low | Medium (DB must support schema) |
Isolated DB | High | Medium | High (Supported in all DBs) |
Isolated using docker | Complete | Medium | High (Supported in all DBs) |
What method should I use?¶
While each method has its pros and cons, for most people, Isolated Schema with shared database is the best method. It provides strong isolation guarantees, customizability with minimal time to launch new tenants.
Third party apps¶
Open source Django multi tenancy apps¶
There are number of third party Django apps which add multi tenancy to Django.
Some of them are
- Django multitenant: https://github.com/citusdata/django-multitenant (Shared SChema, Shared DB, Tables have
tenant_id
) - Django tenant schemas: https://github.com/bernardopires/django-tenant-schemas (Isolated Schemas, shared DB)
- Django db multitenant: https://github.com/mik3y/django-db-multitenant (Isolated DB)
We will look in detail at Django tenant schemas, which is our opinion is the most mature of the Django multi tenancy solutions.
A tour of django-tenant-schemas¶
Install django-tenant-schemas
using pip. pip install django-tenant-schemas
. Verify the version of django-tenant-schemas that got installed.
$ pip freeze | grep django-tenant-schemas
django-tenant-schemas==1.9.0
We will start from our non tenant aware Polls app and add multi tenancy using django-tenant-schemas.
Create a new database, and make sure your Django app picks up the new DB by updating the DATABASE_URL
environment var.
Update your settings to use the tenant-schemas DATABASE_BACKEND
and tenant-schemas DATABASE_ROUTERS
DATABASES["default"]["ENGINE"] = "tenant_schemas.postgresql_backend"
# ...
DATABASE_ROUTERS = ("tenant_schemas.routers.TenantSyncRouter",)
The postgresql_backend
will ensure that the connection has the correct tenant
set, and the TenantSyncRouter
will ensure that the migrations run correctly.
Then create a new app called tenants
with manage.py startapp
, and create a new Client
model
from tenant_schemas.models import TenantMixin
class Client(TenantMixin):
name = models.CharField(max_length=100)
In your settings, change the middleware and set the TENANT_MODEL
.
TENANT_MODEL = "tenants.Client"
# ...
MIDDLEWARE = [
"tenant_schemas.middleware.TenantMiddleware",
# ...
]
tenant-schemas
comes with the concept of SHARED_APPS
and TENANT_APPS
.
The apps in SHARED_APPS
have their tables in public schema, while the apps in TENANT_APPS
have their tables in tenant specific schemas.
SHARED_APPS = ["tenant_schemas", "tenants"]
TENANT_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework.authtoken",
"polls",
]
INSTALLED_APPS = SHARED_APPS + TENANT_APPS
We are almost done. We need to
- Run the migrations in the public schema
- Create the tenants and run migrations in all the tenant schemas
- Create a superuser in tenant schemas
tenant-schemas
has the migrate_schemas
which replaces the migrate
command.
It is tenant aware and will sync SHARED_APPS
to public schema, and TENANT_APPS
to tenant specific schemas.
Run python manage.py migrate_schemas --shared
to sync the public tables.
The run a python shell using python manage.py shell
, and create the two tenants, using
Client.objects.create(name="thor",
schema_name="thor", domain_url="thor.polls.local")
Client.objects.create(name="potter",
schema_name="potter", domain_url="potter.polls.local")
This will create the schemas in the table and run the migrations. You now need to create the superuser in the tenant schema so that you can access the admin.
The tenant_command
command allow running any Django command in the context of any tenant.
python manage.py tenant_command createsuperuser
And we are done. We can now access the tenant admins, create polls and view the tenant specific API endpoints.
The code for this chapter is available at https://github.com/agiliq/building-multi-tenant-applications-with-django/tree/master/tenant-schemas-demo .