# 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:
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.
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:
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
No comments yet.