Como Construir um SaaS White-Label Escalável em Django com Isolamento Real de Dados e Branding em Tempo Real

Published on: 2025-12-29
Post image
pt saas-white-label django-multi-tenant django-tenants postgresql-schemas saas-escalavel isolamento-de-dados-saas branding-white-label htmx-django arquitetura-saas-django plataforma-saas-white-label

Uma plataforma SaaS white-label é um software entregue como serviço em que diferentes empresas usam a mesma base de código, mas com marcas, domínios e aparência próprias. Esse modelo permite que cada cliente final enxergue o produto como se fosse “da sua empresa”, enquanto a operação e a evolução do sistema permanecem centralizadas.

Uma forma robusta de construir esse tipo de sistema em Django combina multi-tenancy (múltiplos inquilinos no mesmo sistema) com isolamento real de dados e uma camada de branding (personalização visual) que se atualiza rapidamente. A arquitetura a seguir utiliza django-tenants para isolar dados em esquemas do PostgreSQL e HTMX para atualizar pré-visualizações de tema sem recarregar páginas.

Conceitos essenciais: white-label, tenant e isolamento de dados

O termo white-label descreve um produto genérico que recebe a marca de quem o revende ou utiliza comercialmente. Em SaaS, cada cliente costuma ser um tenant, ou seja, uma “instância lógica” do sistema com dados e configurações próprias. O requisito central é impedir vazamento de dados entre tenants, mesmo com a mesma aplicação rodando para todos. Para isso, uma estratégia comum é separar os dados por schema (esquema) no banco, mantendo tabelas do tenant em um namespace isolado.

Visão geral da arquitetura com PostgreSQL schemas, django-tenants e HTMX

O PostgreSQL oferece suporte nativo a esquemas, que funcionam como “pastas” de tabelas dentro do mesmo banco. O django-tenants usa esse recurso para alternar automaticamente o schema ativo conforme o domínio acessado, mantendo dados separados sem duplicar infraestrutura. Já o HTMX é uma biblioteca que permite atualizar partes do HTML via requisições HTTP, sem escrever um SPA completo. Com isso, mudanças de cores, fontes e logotipo podem aparecer imediatamente em uma área de prévia.

Estrutura inicial do projeto e dependências

A base do sistema precisa de Django, suporte ao multi-tenant e um driver de banco. O pacote psycopg2 conecta Django ao PostgreSQL, e o Pillow é usado para lidar com upload de imagens, como logotipos. Também é comum separar apps em “tenants” (cadastro de clientes e domínios) e “branding” (configurações visuais e páginas). A seguir está um exemplo de comandos de criação e instalação para compor essa estrutura.

mkdir whitelabel-saas
cd whitelabel-saas

python -m venv .venv
source .venv/bin/activate  # no Windows: .venv\Scripts\activate

pip install django django-tenants psycopg2-binary Pillow

django-admin startproject config .
python manage.py startapp tenants
python manage.py startapp branding

Configuração do banco: engine do django-tenants e roteador

Para o django-tenants funcionar, o Django deve usar um backend específico de banco e um roteador de migrações. O backend “django_tenants.postgresql_backend” garante que a troca de schema seja respeitada nas conexões. O DATABASE_ROUTERS define como migrações e sincronizações são aplicadas entre schema público e schemas de tenants. Essa configuração é a base para separar apps compartilhados e apps por tenant.

# config/settings.py

DATABASES = {
    "default": {
        "ENGINE": "django_tenants.postgresql_backend",
        "NAME": "whitelabel_db",
        "USER": "postgres",
        "PASSWORD": "sua_senha",
        "HOST": "localhost",
        "PORT": "5432",
    }
}

DATABASE_ROUTERS = (
    "django_tenants.routers.TenantSyncRouter",
)

Modelos de tenant: Client e Domain para subdomínios e roteamento

O django-tenants exige dois modelos principais: um modelo de tenant e um modelo de domínio. O modelo de tenant herda de TenantMixin, que automatiza criação e alternância de schema, e o modelo de domínio herda de DomainMixin, que mapeia hostnames para tenants. Campos de branding podem ficar no próprio tenant quando representam identidade principal, como cores e nome da empresa. Com auto_create_schema, o schema é criado no momento em que um tenant é salvo.

# tenants/models.py
from django.db import models
from django_tenants.models import TenantMixin, DomainMixin


class Client(TenantMixin):
    name = models.CharField(max_length=100)
    created_on = models.DateField(auto_now_add=True)

    # Campos de branding do tenant
    primary_color = models.CharField(max_length=7, default="#3B82F6")
    secondary_color = models.CharField(max_length=7, default="#1E40AF")
    logo = models.ImageField(upload_to="logos/", null=True, blank=True)
    company_name = models.CharField(max_length=100)

    auto_create_schema = True

    def __str__(self):
        return self.name


class Domain(DomainMixin):
    pass

Configuração do Django: apps, middleware e URLConfs por schema

A aplicação precisa declarar qual é o modelo de tenant e qual é o modelo de domínio. O TenantMainMiddleware é responsável por interceptar a requisição e definir o schema ativo com base no host acessado. Também é necessário separar URLs do schema público e URLs dos tenants, usando PUBLIC_SCHEMA_URLCONF e ROOT_URLCONF. Essa separação ajuda a manter, por exemplo, o admin e cadastros globais no schema público.

# config/settings.py

INSTALLED_APPS = [
    "django_tenants",
    "tenants",
    "branding",

    "django.contrib.contenttypes",
    "django.contrib.auth",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "django.contrib.admin",
]

TENANT_MODEL = "tenants.Client"
TENANT_DOMAIN_MODEL = "tenants.Domain"

MIDDLEWARE = [
    "django_tenants.middleware.main.TenantMainMiddleware",
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

PUBLIC_SCHEMA_URLCONF = "config.public_urls"
ROOT_URLCONF = "config.tenant_urls"

Apps compartilhados e apps por tenant: SHARED_APPS e TENANT_APPS

No modelo de schemas do django-tenants, algumas tabelas vivem no schema público, enquanto outras existem dentro de cada tenant. Os SHARED_APPS incluem tudo que precisa ser global, como o gerenciamento de tenants e, frequentemente, autenticação administrativa. Os TENANT_APPS

# config/settings.py

SHARED_APPS = (
    "django_tenants",
    "tenants",

    "django.contrib.contenttypes",
    "django.contrib.auth",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.admin",
)

TENANT_APPS = (
    "django.contrib.contenttypes",
    "branding",
)

INSTALLED_APPS = list(SHARED_APPS) + [
    app for app in TENANT_APPS if app not in SHARED_APPS
]

Motor de branding: páginas e configurações por tenant (singleton)

O branding tende a ter duas naturezas: conteúdo (por exemplo, páginas) e configurações de tema (por exemplo, fontes e raio de borda). Um padrão útil é um modelo de configurações como singleton, que significa “apenas uma instância” por tenant, garantindo consistência. A técnica de forçar pk=1 no método save impede múltiplos registros, mantendo leitura simples e previsível. Como o app “branding” é tenant-specific, cada tenant terá seu próprio registro pk=1 no seu schema.

# branding/models.py
from django.db import models


class Page(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.title


class BrandSettings(models.Model):
    # Singleton por tenant
    site_title = models.CharField(max_length=100, default="My SaaS")
    tagline = models.CharField(max_length=200, blank=True)
    footer_text = models.CharField(max_length=200, blank=True)

    # Estilo avançado
    font_family = models.CharField(
        max_length=50,
        default="Inter",
        choices=[
            ("Inter", "Inter"),
            ("Roboto", "Roboto"),
            ("Poppins", "Poppins"),
            ("Montserrat", "Montserrat"),
        ],
    )
    border_radius = models.CharField(
        max_length=10,
        default="0.5rem",
        choices=[
            ("0", "Sharp"),
            ("0.25rem", "Subtle"),
            ("0.5rem", "Rounded"),
            ("1rem", "Very Rounded"),
        ],
    )

    def save(self, *args, **kwargs):
        self.pk = 1  # garante singleton
        super().save(*args, **kwargs)

    def delete(self, *args, **kwargs):
        return  # impede exclusão

    @classmethod
    def load(cls):
        obj, _criado = cls.objects.get_or_create(pk=1)
        return obj

Painel de controle de marca: view de edição e view de prévia

O painel de branding precisa ler o tenant atual e suas configurações, além de permitir atualização por POST. O atributo request.tenant é fornecido pelo django-tenants e representa o Client ativo para aquela requisição. Para manter a experiência consistente, o mesmo endpoint de prévia pode renderizar apenas o fragmento de HTML da área de preview. A proteção com login_required reduz o risco de alteração indevida, exigindo autenticação para acessar as configurações.

# branding/views.py
from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from .models import BrandSettings


@login_required
def brand_settings(request):
    tenant = request.tenant
    settings = BrandSettings.load()

    if request.method == "POST":
        tenant.primary_color = request.POST.get("primary_color")
        tenant.secondary_color = request.POST.get("secondary_color")
        tenant.company_name = request.POST.get("company_name")

        if request.FILES.get("logo"):
            tenant.logo = request.FILES["logo"]

        tenant.save()

        settings.site_title = request.POST.get("site_title")
        settings.tagline = request.POST.get("tagline")
        settings.footer_text = request.POST.get("footer_text")
        settings.font_family = request.POST.get("font_family")
        settings.border_radius = request.POST.get("border_radius")
        settings.save()

        return redirect("brand_settings")

    context = {"tenant": tenant, "settings": settings}
    return render(request, "branding/settings.html", context)


def preview_theme(request):
    tenant = request.tenant
    settings = BrandSettings.load()

    context = {"tenant": tenant, "settings": settings}
    return render(request, "branding/preview.html", context)

Templates com HTMX: atualização de prévia sem recarregar a página

O HTMX funciona via atributos HTML como hx-post, hx-target e hx-trigger, que disparam requisições e substituem trechos do DOM. O formulário pode enviar atualizações para a view de prévia a cada mudança de campo, retornando somente o HTML do painel. Para refletir tema, variáveis CSS como --primary e --font podem ser definidas no template com valores do tenant. Esse padrão cria um “motor de branding” reativo sem a complexidade de um framework de front-end completo.

<!-- templates/branding/settings.html -->
{% load static %}
<!DOCTYPE html>
<html>
<head>
    <title>Brand Settings</title>
    <script src="https://unpkg.com/htmx.org@1.9.10"></script>
    <style>
        :root {
            --primary: {{ tenant.primary_color }};
            --secondary: {{ tenant.secondary_color }};
            --font: {{ settings.font_family }};
            --radius: {{ settings.border_radius }};
        }

        body {
            font-family: var(--font), sans-serif;
            margin: 0;
            padding: 20px;
            background: #f5f5f5;
        }

        .container {
            max-width: 1200px;
            margin: 0 auto;
            display: grid;
            grid-template-columns: 400px 1fr;
            gap: 20px;
        }

        .settings-panel,
        .preview-panel {
            background: white;
            padding: 30px;
            border-radius: var(--radius);
            box-shadow: 0 2px 8px rgba(0,0,0,0.1);
        }

        .preview-panel {
            min-height: 600px;
        }

        .form-group {
            margin-bottom: 20px;
        }

        label {
            display: block;
            margin-bottom: 5px;
            font-weight: 600;
            color: #333;
        }

        input[type="text"],
        input[type="color"],
        select {
            width: 100%;
            padding: 10px;
            border: 1px solid #ddd;
            border-radius: var(--radius);
            font-size: 14px;
        }

        input[type="color"] {
            height: 50px;
            cursor: pointer;
        }

        button {
            background: var(--primary);
            color: white;
            border: none;
            padding: 12px 24px;
            border-radius: var(--radius);
            cursor: pointer;
            font-size: 16px;
            font-weight: 600;
            width: 100%;
        }

        button:hover {
            opacity: 0.9;
        }

        .logo-preview {
            max-width: 200px;
            margin-top: 10px;
            border-radius: var(--radius);
        }

        h1, h2 {
            color: var(--primary);
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="settings-panel">
            <h2>Brand Settings</h2>

            <form
                hx-post="{% url 'brand_settings' %}"
                hx-target="#preview"
                hx-swap="innerHTML"
                enctype="multipart/form-data">
                {% csrf_token %}

                <div class="form-group">
                    <label>Company Name</label>
                    <input type="text" name="company_name"
                           value="{{ tenant.company_name }}"
                           hx-post="{% url 'preview_theme' %}"
                           hx-trigger="keyup changed delay:500ms"
                           hx-target="#preview">
                </div>

                <div class="form-group">
                    <label>Site Title</label>
                    <input type="text" name="site_title"
                           value="{{ settings.site_title }}"
                           hx-post="{% url 'preview_theme' %}"
                           hx-trigger="keyup changed delay:500ms"
                           hx-target="#preview">
                </div>

                <div class="form-group">
                    <label>Tagline</label>
                    <input type="text" name="tagline"
                           value="{{ settings.tagline }}"
                           hx-post="{% url 'preview_theme' %}"
                           hx-trigger="keyup changed delay:500ms"
                           hx-target="#preview">
                </div>

                <div class="form-group">
                    <label>Primary Color</label>
                    <input type="color" name="primary_color"
                           value="{{ tenant.primary_color }}"
                           hx-post="{% url 'preview_theme' %}"
                           hx-trigger="change"
                           hx-target="#preview">
                </div>

                <div class="form-group">
                    <label>Secondary Color</label>
                    <input type="color" name="secondary_color"
                           value="{{ tenant.secondary_color }}"
                           hx-post="{% url 'preview_theme' %}"
                           hx-trigger="change"
                           hx-target="#preview">
                </div>

                <div class="form-group">
                    <label>Font Family</label>
                    <select name="font_family"
                            hx-post="{% url 'preview_theme' %}"
                            hx-trigger="change"
                            hx-target="#preview">
                        <option value="Inter" {% if settings.font_family == 'Inter' %}selected{% endif %}>Inter</option>
                        <option value="Roboto" {% if settings.font_family == 'Roboto' %}selected{% endif %}>Roboto</option>
                        <option value="Poppins" {% if settings.font_family == 'Poppins' %}selected{% endif %}>Poppins</option>
                        <option value="Montserrat" {% if settings.font_family == 'Montserrat' %}selected{% endif %}>Montserrat</option>
                    </select>
                </div>

                <div class="form-group">
                    <label>Border Radius</label>
                    <select name="border_radius"
                            hx-post="{% url 'preview_theme' %}"
                            hx-trigger="change"
                            hx-target="#preview">
                        <option value="0" {% if settings.border_radius == '0' %}selected{% endif %}>Sharp</option>
                        <option value="0.25rem" {% if settings.border_radius == '0.25rem' %}selected{% endif %}>Subtle</option>
                        <option value="0.5rem" {% if settings.border_radius == '0.5rem' %}selected{% endif %}>Rounded</option>
                        <option value="1rem" {% if settings.border_radius == '1rem' %}selected{% endif %}>Very Rounded</option>
                    </select>
                </div>

                <div class="form-group">
                    <label>Logo</label>
                    <input type="file" name="logo" accept="image/*">
                    {% if tenant.logo %}
                        <img src="{{ tenant.logo.url }}" class="logo-preview">
                    {% endif %}
                </div>

                <button type="submit">Save Changes</button>
            </form>
        </div>

        <div class="preview-panel" id="preview">
            {% include 'branding/preview.html' %}
        </div>
    </div>
</body>
</html>

Template de prévia: fragmento reutilizável para renderização parcial

Para o HTMX, a prévia funciona bem como um template separado, pois o servidor retorna apenas o HTML que será trocado no elemento alvo. Variáveis CSS no próprio fragmento garantem que cores e estilos estejam alinhados com o que foi enviado no POST. O logotipo é exibido quando existe arquivo associado ao tenant, mantendo a identidade visual por subdomínio. Esse fragmento pode evoluir para simular componentes reais do produto, como botões, cards e cabeçalhos.

<!-- templates/branding/preview.html -->
<style>
    :root {
        --primary: {{ tenant.primary_color }};
        --secondary: {{ tenant.secondary_color }};
        --font: {{ settings.font_family }};
        --radius: {{ settings.border_radius }};
    }

    .preview-header {
        background: var(--primary);
        color: white;
        padding: 20px;
        border-radius: var(--radius);
        margin-bottom: 20px;
    }

    .preview-button {
        background: var(--secondary);
        color: white;
        padding: 10px 20px;
        border: none;
        border-radius: var(--radius);
        cursor: pointer;
        font-family: var(--font), sans-serif;
    }

    .preview-card {
        border: 2px solid var(--primary);
        padding: 20px;
        border-radius: var(--radius);
        margin: 10px 0;
    }
</style>

<div class="preview-content">
    <div class="preview-header">
        {% if tenant.logo %}
            <img src="{{ tenant.logo.url }}" style="max-height: 50px; margin-bottom: 10px;">
        {% endif %}
        <h1 style="margin: 0; font-family: var(--font), sans-serif;">
            {{ tenant.company_name }}
        </h1>
        <p style="margin: 10px 0 0 0; opacity: 0.9;">{{ settings.tagline }}</p>
    </div>

    <h2 style="color: var(--primary); font-family: var(--font), sans-serif;">
        Welcome to {{ settings.site_title }}
    </h2>

    <p style="font-family: var(--font), sans-serif;">
        This is a live preview of your brand settings. Changes appear instantly as you adjust the settings.
    </p>

    <div class="preview-card">
        <h3 style="color: var(--primary); font-family: var(--font), sans-serif;">
            Sample Card
        </h3>
        <p style="font-family: var(--font), sans-serif;">
            Your content will look like this with the current theme settings.
        </p>
        <button class="preview-button">Action Button</button>
    </div>

    <div class="preview-card">
        <h3 style="color: var(--primary); font-family: var(--font), sans-serif;">
            Another Card
        </h3>
        <p style="font-family: var(--font), sans-serif;">
            Notice how the colors, fonts, and border radius update in real-time.
        </p>
    </div>
</div>

Configuração de URLs: rotas de tenant e rotas públicas

O schema público costuma expor rotas como /admin, onde a gestão central é realizada. Já as rotas do tenant ficam no URLConf específico e incluem recursos do produto, como o painel de branding e a prévia. Essa divisão impede que endpoints do produto apareçam no contexto público sem tenant definido. Também reduz ambiguidade de roteamento quando há múltiplos domínios e subdomínios.

# config/tenant_urls.py
from django.urls import path
from branding import views

urlpatterns = [
    path("settings/", views.brand_settings, name="brand_settings"),
    path("preview/", views.preview_theme, name="preview_theme"),
]
# config/public_urls.py
from django.contrib import admin
from django.urls import path

urlpatterns = [
    path("admin/", admin.site.urls),
]

Migrações e criação do primeiro tenant e domínio

O django-tenants oferece comandos que executam migrações no schema público e nos schemas dos tenants. O comando migrate_schemas --shared prepara o schema público, enquanto migrate_schemas aplica migrações nos schemas de tenants existentes. A criação do tenant normalmente é feita no shell do Django para garantir um primeiro domínio funcional. Ao salvar um Client com auto_create_schema habilitado, o schema correspondente é criado e fica pronto para receber tabelas dos apps do tenant.

python manage.py migrate_schemas --shared
python manage.py migrate_schemas
# python manage.py shell
from tenants.models import Client, Domain

tenant = Client(
    schema_name="acme",
    name="Acme Corporation",
    company_name="Acme Corp",
    primary_color="#FF6B35",
    secondary_color="#004E89",
)
tenant.save()

domain = Domain()
domain.domain = "acme.localhost"
domain.tenant = tenant
domain.is_primary = True
domain.save()

Teste local com subdomínios: mapeamento no arquivo hosts

Em ambiente local, subdomínios como “acme.localhost” precisam resolver para o IP da máquina. Isso é feito adicionando entradas no arquivo hosts do sistema operacional, apontando o nome para 127.0.0.1. Esse mecanismo simula o comportamento de DNS, permitindo que o middleware encontre o tenant correto pelo host. Com o servidor do Django ativo, cada subdomínio passa a operar com schema e branding próprios.

# Exemplo de entradas para o arquivo hosts
127.0.0.1 acme.localhost
127.0.0.1 client2.localhost

python manage.py runserver

Cenários de produção: domínios customizados, performance, segurança e arquivos

Em produção, domínios customizados exigem verificação de propriedade, pois apontamentos DNS podem ser manipulados. Uma prática comum é registrar o domínio em uma tabela, instruir a criação de registros DNS e só ativar o domínio após validação, reduzindo riscos de sequestro de host. Em performance, consultas repetidas para identificar tenant podem ser otimizadas com cache e pool de conexões no PostgreSQL. Em segurança, controles como limitação de taxa por tenant, boas práticas de sessão e proteção CSRF tornam-se essenciais, especialmente em telas de configuração de marca.

Extensões naturais do motor de branding: CSS customizado, e-mails e API por tenant

Depois do núcleo estar estável, é comum ampliar o branding para aceitar CSS customizado, mantendo validação rigorosa para evitar injeções maliciosas. Outro caminho é gerar e-mails transacionais com cores e logotipo do tenant, mantendo consistência de marca em notificações. Em integrações, uma API por tenant permite conectar sistemas externos com autenticação ciente do tenant, evitando confusão de contexto. Um painel de métricas por tenant também apoia faturamento e planejamento de capacidade, associando uso a limites e planos.

Conclusão

Uma plataforma SaaS white-label bem estruturada depende de isolamento forte e de uma camada de marca fácil de administrar. O uso de django-tenants com schemas do PostgreSQL cria separação efetiva de dados, enquanto o HTMX viabiliza uma experiência dinâmica para personalização visual com simplicidade. Com modelos de tenant e domínio, middleware apropriado e divisão entre apps compartilhados e apps por tenant, a base fica consistente e escalável. O motor de branding com configurações singleton e prévia em tempo quase real completa o ciclo, formando um sistema coeso com início, meio e fim bem definidos.