Migrating from Custom Django to SaaS Pegasus
medium
24 hours
django
python
teams
billing

Migrating from Custom Django to SaaS Pegasus

Transform your handcrafted Django app into a production-ready SaaS with Pegasus's battle-tested patterns for auth, teams, billing, and deployment.

Prerequisites

  • Existing Django application
  • Understanding of Django models and views
  • Basic knowledge of Stripe or payment integration

Migrating from Custom Django to SaaS Pegasus

SaaS Pegasus is a Django boilerplate that provides a complete foundation for subscription-based SaaS applications. If you've been building Django from scratch and want to adopt proven patterns for auth, teams, and billing, this guide is for you.

Why Migrate to Pegasus?#

Migrate if:

  • Your custom auth implementation is becoming a maintenance burden
  • You need teams/multi-tenancy and don't want to build it
  • Stripe subscription handling is complex and error-prone
  • You want a standardized deployment pipeline
  • You're spending too much time on infrastructure vs. features

Keep your custom setup if:

  • Your authentication needs are truly unique
  • You've already solved these problems well
  • Pegasus's opinions conflict with your architecture
  • You prefer complete control over all dependencies

What Pegasus Provides#

Your Custom CodeSaaS Pegasus
DIY authenticationdjango-allauth with social providers
Custom team modelsProven team/membership architecture
Stripe integrationComplete subscription handling
Email sendingConfigurable email backends
Celery setupReady-to-use background tasks
FrontendReact, Vue, HTMX, or Alpine options

Step 1: Generate Your Pegasus Project#

Pegasus uses a configuration wizard to generate customized code:

# Visit saaspegasus.com and configure your project
# Download the generated codebase

# Or use their CLI
pip install pegasus-installer
pegasus create my-project --config config.yaml

Configuration Options#

# config.yaml example
project_name: "MyApp"
team_model: true
billing: stripe
frontend: react
email_backend: anymail
background_tasks: celery
database: postgresql

Step 2: Understand the Project Structure#

pegasus-project/
├── apps/
│   ├── users/           # User authentication
│   ├── teams/           # Team/organization model
│   ├── subscriptions/   # Billing integration
│   └── web/             # Main app pages
├── assets/              # Frontend code
├── pegasus/             # Core Pegasus code
└── templates/           # HTML templates

Step 3: Migrate Your Database#

Compare Models#

# Your custom User model
class User(AbstractUser):
    company = models.CharField(max_length=100, blank=True)
    phone = models.CharField(max_length=20, blank=True)
    stripe_customer_id = models.CharField(max_length=100, blank=True)

# Pegasus User model
class CustomUser(AbstractUser):
    # Pegasus uses teams for organization
    # Avatar handling
    avatar = models.FileField(upload_to='avatars/', blank=True)
    
    # Subscription is on Team, not User

Create Migration Script#

# scripts/migrate_to_pegasus.py
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
django.setup()

from django.db import connection
from apps.users.models import CustomUser
from apps.teams.models import Team, Membership

def migrate_users():
    """Migrate users from old schema to Pegasus schema."""
    
    with connection.cursor() as cursor:
        # Get old users
        cursor.execute("""
            SELECT id, email, username, first_name, last_name, 
                   company, phone, stripe_customer_id, 
                   date_joined, is_active
            FROM old_users_user
        """)
        
        old_users = cursor.fetchall()
    
    for row in old_users:
        (old_id, email, username, first_name, last_name,
         company, phone, stripe_customer_id, date_joined, is_active) = row
        
        # Create user in Pegasus
        user, created = CustomUser.objects.get_or_create(
            email=email,
            defaults={
                'username': username,
                'first_name': first_name,
                'last_name': last_name,
                'date_joined': date_joined,
                'is_active': is_active,
            }
        )
        
        if created:
            # Create personal team
            team = Team.objects.create(
                name=company if company else f"{first_name}'s Team",
                slug=email.split('@')[0].lower()[:30],
            )
            
            # Add as owner
            Membership.objects.create(
                team=team,
                user=user,
                role=Membership.ROLE_ADMIN,
            )
            
            # Migrate Stripe customer to team
            if stripe_customer_id:
                team.stripe_customer_id = stripe_customer_id
                team.save()
        
        print(f"Migrated user: {email}")

if __name__ == '__main__':
    migrate_users()
    print("Migration complete!")

Migrate Custom Models#

# Your custom model
class Project(models.Model):
    user = models.ForeignKey('users.User', on_delete=models.CASCADE)
    name = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

# Pegasus team-scoped model
class Project(models.Model):
    team = models.ForeignKey('teams.Team', on_delete=models.CASCADE)
    created_by = models.ForeignKey(
        'users.CustomUser', 
        on_delete=models.SET_NULL,
        null=True
    )
    name = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    
    class Meta:
        ordering = ['-created_at']

Step 4: Migrate Authentication#

Your Custom Auth Views#

# Your custom login view (probably)
class CustomLoginView(LoginView):
    template_name = 'auth/login.html'
    
    def get_success_url(self):
        return reverse('dashboard')

Pegasus Auth (django-allauth)#

Pegasus uses django-allauth which handles:

  • Email/password authentication
  • Social authentication (Google, GitHub, etc.)
  • Email verification
  • Password reset
# settings.py (Pegasus configured)
AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.ModelBackend',
    'allauth.account.auth_backends.AuthenticationBackend',
]

ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_EMAIL_VERIFICATION = 'mandatory'
ACCOUNT_AUTHENTICATION_METHOD = 'email'

# Social auth
SOCIALACCOUNT_PROVIDERS = {
    'google': {
        'APP': {
            'client_id': os.environ.get('GOOGLE_CLIENT_ID'),
            'secret': os.environ.get('GOOGLE_CLIENT_SECRET'),
        },
        'SCOPE': ['profile', 'email'],
    }
}

Update OAuth Callbacks#

In Google Cloud Console, update callback URLs:

https://your-domain.com/accounts/google/login/callback/

Step 5: Migrate Views to Team Context#

Before: User-Scoped Views#

# Your custom view
class ProjectListView(LoginRequiredMixin, ListView):
    model = Project
    template_name = 'projects/list.html'
    
    def get_queryset(self):
        return Project.objects.filter(user=self.request.user)

After: Team-Scoped Views#

# Pegasus team-scoped view
from apps.teams.mixins import TeamMixin

class ProjectListView(TeamMixin, ListView):
    model = Project
    template_name = 'projects/list.html'
    
    def get_queryset(self):
        # TeamMixin provides self.team
        return Project.objects.filter(team=self.team)

Pegasus Team Mixins#

# apps/teams/mixins.py (provided by Pegasus)
class TeamMixin:
    """Mixin that adds team context to views."""
    
    def dispatch(self, request, *args, **kwargs):
        # Get team from URL or session
        self.team = get_object_or_404(
            Team, 
            slug=kwargs.get('team_slug')
        )
        
        # Verify membership
        membership = Membership.objects.filter(
            team=self.team,
            user=request.user
        ).first()
        
        if not membership:
            raise PermissionDenied("Not a member of this team")
        
        self.membership = membership
        return super().dispatch(request, *args, **kwargs)
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['team'] = self.team
        context['membership'] = self.membership
        return context

Step 6: Migrate Billing#

Your Custom Stripe Integration#

# Your custom checkout
import stripe

def create_checkout_session(request):
    stripe.api_key = settings.STRIPE_SECRET_KEY
    
    session = stripe.checkout.Session.create(
        customer_email=request.user.email,
        mode='subscription',
        line_items=[{'price': 'price_xxx', 'quantity': 1}],
        success_url=f'{settings.BASE_URL}/success/',
        cancel_url=f'{settings.BASE_URL}/pricing/',
        metadata={'user_id': request.user.id},
    )
    
    return redirect(session.url)

Pegasus Subscription Handling#

# Pegasus provides subscription models and views
from djstripe.models import Subscription

# In your view
class DashboardView(TeamMixin, TemplateView):
    template_name = 'dashboard.html'
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        
        # Pegasus integrates with dj-stripe
        context['subscription'] = self.team.subscription
        context['is_subscribed'] = self.team.has_active_subscription()
        
        return context

Checkout with Pegasus#

# apps/subscriptions/views.py (Pegasus pattern)
from apps.subscriptions.helpers import create_stripe_checkout_session

@login_required
@team_required
def create_checkout(request, team_slug):
    team = get_object_or_404(Team, slug=team_slug)
    price_id = request.POST.get('price_id')
    
    # Pegasus helper handles customer creation
    checkout_session = create_stripe_checkout_session(
        team=team,
        user=request.user,
        price_id=price_id,
        success_url=f'{settings.BASE_URL}/a/{team.slug}/subscription/success/',
        cancel_url=f'{settings.BASE_URL}/pricing/',
    )
    
    return redirect(checkout_session.url)

Migrate Existing Subscriptions#

# scripts/migrate_subscriptions.py
from apps.teams.models import Team
from djstripe.models import Customer, Subscription
import stripe

stripe.api_key = settings.STRIPE_SECRET_KEY

def migrate_subscriptions():
    # Get teams with old Stripe customer IDs
    teams = Team.objects.exclude(stripe_customer_id='')
    
    for team in teams:
        # Sync customer from Stripe
        stripe_customer = stripe.Customer.retrieve(team.stripe_customer_id)
        
        # Create dj-stripe Customer
        customer, _ = Customer.get_or_create(id=stripe_customer.id)
        
        # Link to team
        team.customer = customer
        team.save()
        
        # Sync subscriptions
        subscriptions = stripe.Subscription.list(
            customer=team.stripe_customer_id,
            status='active'
        )
        
        for sub in subscriptions.data:
            Subscription.sync_from_stripe_data(sub)
        
        print(f"Migrated subscriptions for team: {team.name}")

Step 7: Migrate Templates#

Your Custom Templates#

<!-- Your template -->
{% extends "base.html" %}

{% block content %}
<div class="container">
    <h1>Welcome, {{ user.first_name }}</h1>
    
    {% for project in projects %}
    <div class="project-card">
        <h3>{{ project.name }}</h3>
        <p>{{ project.description }}</p>
    </div>
    {% endfor %}
</div>
{% endblock %}

Pegasus Templates#

<!-- Pegasus pattern -->
{% extends "web/app/app_base.html" %}
{% load team_tags %}

{% block app_content %}
<div class="container mx-auto px-4 py-8">
    <h1 class="text-2xl font-bold mb-6">
        Welcome to {{ team.name }}
    </h1>
    
    {% if membership.is_admin %}
    <a href="{% url 'teams:manage_team' team.slug %}" 
       class="btn btn-primary mb-4">
        Team Settings
    </a>
    {% endif %}
    
    <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
        {% for project in projects %}
        <div class="card">
            <div class="card-body">
                <h3 class="card-title">{{ project.name }}</h3>
                <p>{{ project.description|truncatewords:20 }}</p>
            </div>
        </div>
        {% endfor %}
    </div>
</div>
{% endblock %}

Pegasus Template Tags#

# Pegasus provides team-aware template tags
{% load team_tags %}

{% if membership.is_admin %}
    <!-- Admin-only content -->
{% endif %}

{% if team.has_active_subscription %}
    <!-- Subscriber content -->
{% else %}
    <a href="{% url 'subscriptions:checkout' team.slug %}">
        Upgrade to access this feature
    </a>
{% endif %}

Step 8: Migrate Background Tasks#

Your Custom Celery Setup#

# Your celery.py
from celery import Celery

app = Celery('myproject')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

Pegasus Celery Configuration#

Pegasus comes with Celery pre-configured:

# config/celery.py (Pegasus)
import os
from celery import Celery

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')

app = Celery('myproject')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

# Pegasus adds team-aware task helpers
@app.task(bind=True)
def team_task(self, team_id, *args, **kwargs):
    from apps.teams.models import Team
    team = Team.objects.get(id=team_id)
    # Task logic here

Migrate Your Tasks#

# Your task
@shared_task
def send_report_email(user_id):
    user = User.objects.get(id=user_id)
    # Send email logic

# Pegasus pattern - team aware
@shared_task
def send_report_email(team_id, user_id):
    from apps.teams.models import Team
    from apps.users.models import CustomUser
    
    team = Team.objects.get(id=team_id)
    user = CustomUser.objects.get(id=user_id)
    
    # Use Pegasus email helpers
    from apps.utils.email import send_email
    send_email(
        recipient=user.email,
        subject=f"Report for {team.name}",
        template='emails/report.html',
        context={'team': team, 'user': user},
    )

Step 9: Update URLs#

Your Custom URLs#

# urls.py
urlpatterns = [
    path('dashboard/', views.dashboard, name='dashboard'),
    path('projects/', views.project_list, name='project_list'),
    path('projects/<int:pk>/', views.project_detail, name='project_detail'),
    path('settings/', views.settings, name='settings'),
]

Pegasus URL Structure#

# urls.py (Pegasus pattern)
urlpatterns = [
    # Public pages
    path('', views.home, name='home'),
    path('pricing/', views.pricing, name='pricing'),
    
    # Team-scoped URLs
    path('a/<slug:team_slug>/', include([
        path('', views.team_dashboard, name='team_dashboard'),
        path('projects/', views.project_list, name='project_list'),
        path('projects/<int:pk>/', views.project_detail, name='project_detail'),
        path('settings/', include('apps.teams.urls')),
        path('billing/', include('apps.subscriptions.urls')),
    ])),
    
    # User account URLs
    path('accounts/', include('allauth.urls')),
]

Step 10: Test Everything#

Testing Checklist#

  • User registration works
  • Email verification sends
  • Social login (Google, GitHub) works
  • Teams are created for new users
  • Team invitations work
  • Role-based access enforced
  • Subscriptions process correctly
  • Webhooks update team subscription status
  • Existing data migrated correctly
  • Background tasks execute

Run Pegasus Tests#

# Pegasus includes comprehensive tests
python manage.py test apps.users
python manage.py test apps.teams
python manage.py test apps.subscriptions

# Run all tests
python manage.py test

Common Challenges#

1. User Model Conflicts#

Problem: Pegasus expects CustomUser, you have different fields.

Solution: Add your custom fields to Pegasus's user model:

# apps/users/models.py
class CustomUser(AbstractUser):
    # Pegasus fields
    avatar = models.FileField(upload_to='avatars/', blank=True)
    
    # Your custom fields
    phone = models.CharField(max_length=20, blank=True)
    company = models.CharField(max_length=100, blank=True)
    timezone = models.CharField(max_length=50, default='UTC')

2. URL Namespace Conflicts#

Problem: Your URL names conflict with Pegasus.

Solution: Use app_name namespaces:

# Your app urls.py
app_name = 'myapp'

urlpatterns = [
    path('projects/', views.list, name='project_list'),
]

# Usage
{% url 'myapp:project_list' team.slug %}

3. Template Override Conflicts#

Problem: Pegasus templates have different block names.

Solution: Map your blocks to Pegasus blocks:

<!-- Create a compatibility layer -->
{% extends "web/app/app_base.html" %}

{% block app_content %}
    {% block content %}{% endblock %}
{% endblock %}

Timeline Estimate#

PhaseEstimated Time
Pegasus Setup2 hours
User Migration3 hours
Model Migration4 hours
View Updates5 hours
Template Updates4 hours
Billing Migration3 hours
Testing3 hours
Total~24 hours

Conclusion#

Migrating from custom Django to SaaS Pegasus gives you a production-tested foundation for teams, billing, and authentication. The main work is adapting your user-scoped code to team-scoped patterns and migrating existing data.

Pegasus's documentation is comprehensive, and their support community is helpful. Take advantage of both during your migration journey.

#django#python#teams#billing

Not sure which boilerplate to choose?

Take our 2-minute quiz and get personalized recommendations.

Take the Quiz
Migrating from Custom Django to SaaS Pegasus | MyStarterStack