Back to Home

Django SaaS boilerplate Shipyard: production template without routine

Analysis of Shipyard — production-ready Django boilerplate for SaaS applications. Breakdown of multi-tenancy architecture, Stripe integration and technical solutions that save 2 weeks of development. Code examples and best practices.

Shipyard: Production-ready Django template for SaaS
Advertisement 728x90

# Shipyard: How to Create a Production-Ready Django SaaS Boilerplate Without the Tedious Setup

Starting a new Django project for a SaaS product usually eats up 2-3 weeks just setting up the basic components. Shipyard solves this by providing a production-ready boilerplate with pre-configured modules. We break down the architecture and technical solutions that save development time.

Stack and Project Structure

Shipyard is built on a modern stack optimized for production workloads. Key components:

  • Django 5 + DRF 3.15: API-first approach without HTML templates
  • PostgreSQL 16 + Redis 7: Separate containers for the database and cache
  • Celery 5 + Beat + Flower: Asynchronous tasks and monitoring
  • Docker Compose: Two configs — one for development and one for production
  • Stripe Webhooks: Event handling with idempotency
  • Multi-tenancy with RBAC: Chain of User → TeamMembership → Team

The project structure is strictly modular:

Google AdInline article slot
shipyard/
├── apps/
│   ├── core/
│   ├── users/
│   ├── teams/
│   ├── billing/
│   ├── notifications/
│   └── api/
├── config/
├── docker/
└── docker-compose.yml

Each app handles its own area of responsibility. For example, billing contains subscription models, while api handles DRF infrastructure without business logic.

Base Models Architecture

The key element is the abstract classes in core/models.py:

python
# apps/core/models.py
import uuid
from django.db import models

class TimestampedModel(models.Model):
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        abstract = True

class UUIDModel(models.Model):
    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False,
    )

    class Meta:
        abstract = True

These mixins are used in all models. UUIDs instead of integer primary keys were chosen deliberately: predictable IDs in URLs are insecure, and migrating from integer to UUID in a live database is painful. TimestampedModel provides change auditing without code duplication.

Google AdInline article slot

The users app implements a custom User model with email as the primary identifier:

python
# apps/users/models.py
class User(UUIDModel, AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(unique=True)
    full_name = models.CharField(max_length=255, blank=True)
    is_email_verified = models.BooleanField(default=False)
    stripe_customer_id = models.CharField(max_length=255, blank=True, null=True)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = ["full_name"]

The stripe_customer_id field is added to the user model for cases where billing is tied to an individual. Verification and password reset tokens are handled separately via EmailVerificationToken and PasswordResetToken models — no cache, just database for auditing.

Multi-Tenancy via RBAC

Shipyard uses a pragmatic approach to multi-tenancy. Instead of schema separation (django-tenants) or a global tenant_id, it employs an ownership chain:

Google AdInline article slot
User → TeamMembership → Team

TeamMembership acts as a through-table with a role field. Team stores limits (max_members, max_projects), denormalized from Plan for fast checks without JOINs. This reduces query complexity when working with limits.

Three roles are implemented via DRF permissions:

python
# apps/teams/permissions.py
class IsTeamMember(BasePermission):
    def has_permission(self, request, view):
        team_id = view.kwargs.get('team_id')
        return TeamMembership.objects.filter(
            user=request.user,
            team_id=team_id
        ).exists()

The pattern is mechanical: all ViewSets filter the queryset by team_id from the URL. This reduces the risk of data leaks between tenants compared to manually adding tenant_id to every query.

Billing System and Webhooks

The Plan model mirrors Stripe Product + Prices:

python
class Plan(models.Model):
    stripe_product_id = models.CharField(max_length=255)
    stripe_price_id_monthly = models.CharField(max_length=255)
    stripe_price_id_yearly = models.CharField(max_length=255)
    max_members = models.PositiveIntegerField()
    max_projects = models.PositiveIntegerField()

Denormalized limits in Plan allow instant quota checks without hitting Stripe. When creating a team, a post_save signal provisions the Stripe customer — the team is ready for payment right away.

Webhook handling is built on idempotency. The WebhookEvent model tracks processed events:

python
class WebhookEvent(models.Model):
    event_id = models.CharField(max_length=255, unique=True)
    event_type = models.CharField(max_length=255)
    payload = models.JSONField()
    processed_at = models.DateTimeField(null=True)

This prevents duplicate event processing on retries from Stripe.

Key Highlights

  • UUID instead of integer PK — decision made once and for all; migration in a live DB is impossible
  • Centralized health checks — endpoints /health/ and /ready/ for integration with Docker and Kubernetes
  • DRF infrastructure in a separate app — versioning, pagination, and error handling separated from business logic
  • Email auditing via EmailLog — logging every sent email for support
  • Two Docker Compose setups — dev config with volumes, prod with multi-stage builds

Stripe integration requires special attention to idempotency. Every webhook event is checked for uniqueness via event_id. This is critical for operations like charges, where reprocessing would lead to double billing. Shipyard uses the WebhookEvent model as a natural locking mechanism.

Notifications feature a templating system with a base layout. All emails (welcome, verify_email, invitation) inherit a common structure for easy maintenance. Templates are stored in apps/notifications/templates/notifications/ with separate HTML and text versions.

The production Docker Compose config uses multi-stage builds. The first stage installs dependencies, the second copies the code. This shrinks the final image size and speeds up deployment. For CI/CD, three GitHub Actions workflows are set up: lint tests on PRs, image builds on merge to main, and deployment on release tags.

— Editorial Team

Advertisement 728x90

Read Next