Как я перестал копипастить одно и то же в каждом Django-проекте и собрал boilerplate
Каждый раз, когда начинаешь новый SaaS-проект на Django, первые две недели уходят на одно и то же. Сначала — кастомная модель пользователя с UUID вместо integer PK, потому что потом не переедешь. Потом JWT-аутентификация, настройка SimpleJWT, написание RegisterView, LoginView, LogoutView — всё это уже было в прошлом проекте, но лежит в другом репозитории и просто так не скопируешь. Дальше Docker Compose: сервисы web, db, redis, celery, celery-beat, flower — шесть штук, которые надо поднять и связать между собой. Потом разбираться с Celery, который в новой версии изменил синтаксис конфига. Stripe webhooks с идемпотентностью — отдельная история. Мультиарендность, роли, permissions — ещё неделя.
В итоге к первой рабочей фиче добираешься к концу третьей недели.
Добавь сюда ещё один нюанс: каждый раз это проект с немного другим стеком. Где-то Kafka вместо Redis, где-то allauth с самого начала, где-то биллинг на пользователя, а не на команду. Но ядро остаётся одним: каждый раз перед стартом ты тратишь одинаково одни и те же две недели. Вот это и хотелось исправить.
Я прошёл через это несколько раз и в какой-то момент решил, что хватит. Собрал Django SaaS boilerplate под названием Shipyard — не как набор сниппетов в Notion, а как полноценный, готовый к production репозиторий, который можно клонировать и сразу писать продуктовую логику.
В этой статье разберу, что там внутри, почему выбраны именно эти компоненты и какие конкретные технические решения показались мне наиболее интересными.
Что входит в Shipyard
Прежде чем переходить к деталям — стек целиком.
Django 5 + Django REST Framework 3.15 — API-first с самого начала, не как надстройка над HTML-шаблонами. DRF выбран потому, что он де-факто стандарт, хорошо документирован и у большинства джуниоров в команде не вызовет вопросов.
PostgreSQL 16 + Redis 7 — PostgreSQL как основная БД: UUID-поля, JSONB для метаданных планов, pg_trgm для будущего поиска. Redis — брокер для Celery и кэш. Два отдельных контейнера, не один Redis на всё подряд.
Celery 5 + Celery Beat + Flower — асинхронные задачи (отправка email, синхронизация со Stripe), периодические задачи через Beat, мониторинг через Flower на порту 5555. Beat хранит расписание в БД через django-celery-beat, а не в хардкоженном CELERYBEAT_SCHEDULE.
Docker Compose — два файла: docker-compose.yml для разработки и docker-compose.prod.yml для production. Dev-конфиг монтирует локальную директорию как volume, prod использует многоэтапную сборку образа. Подробнее об entrypoint — ниже.
GitHub Actions CI/CD — три workflow: ci.yml (lint + тесты на каждый push/PR), build.yml (сборка и пуш Docker-образа при мерже в main), deploy.yml (деплой по release-тегу).
Stripe subscriptions + webhooks — модели Plan, Subscription, Invoice, WebhookEvent. Checkout session, customer portal, обработка webhook-событий с идемпотентностью.
Мультиарендность с RBAC — цепочка User → TeamMembership → Team. Три роли: owner, admin, member. DRF permissions на уровне view и объекта.
Django Unfold — тема для Django Admin. Не принципиально, но стандартный admin в 2025 году выглядит dated, а Unfold даёт нормальный UI без написания собственного шаблона.
Структура проекта:
textshipyard/├── apps/│ ├── core/ # базовые модели, health check, утилиты│ ├── users/ # кастомный User, JWT auth, email verification│ ├── teams/ # Team, TeamMembership, роли, приглашения│ ├── billing/ # Plan, Subscription, Invoice, WebhookEvent│ ├── notifications/ # Celery tasks для email, EmailLog│ └── api/ # DRF router, versioning, throttling├── config/│ ├── settings/│ │ ├── base.py│ │ ├── development.py│ │ └── production.py│ └── celery.py├── docker/│ ├── dev/│ └── prod/└── docker-compose.yml
shipyard/ ├── apps/ │ ├── core/ # базовые модели, health check, утилиты │ ├── users/ # кастомный User, JWT auth, email verification │ ├── teams/ # Team, TeamMembership, роли, приглашения │ ├── billing/ # Plan, Subscription, Invoice, WebhookEvent │ ├── notifications/ # Celery tasks для email, EmailLog │ └── api/ # DRF router, versioning, throttling ├── config/ │ ├── settings/ │ │ ├── base.py │ │ ├── development.py │ │ └── production.py │ └── celery.py ├── docker/ │ ├── dev/ │ └── prod/ └── docker-compose.yml
Архитектура приложений
core
Здесь живут два абстрактных миксина, которые используются почти везде:
python# apps/core/models.pyimport uuidfrom django.db import modelsclass TimestampedModel(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: abstract = Trueclass UUIDModel(models.Model): id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, ) class Meta: abstract = True
# 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
Большинство моделей наследует оба. UUID как первичный ключ — выбор сделан однажды, чтобы не возвращаться к нему потом. Предсказуемые auto-increment ID в URL — не лучшая идея с точки зрения безопасности, и переезжать с integer на UUID в существующей БД болезненно.
Кроме моделей, здесь HealthCheckView и ReadinessView по путям /health/ и /ready/ — нужны для Docker healthcheck и Kubernetes liveness/readiness probe.
users
Кастомная модель User с email как основным идентификатором:
python# apps/users/models.pyclass User(UUIDModel, AbstractBaseUser, PermissionsMixin): email = models.EmailField(unique=True) full_name = models.CharField(max_length=255, blank=True) avatar = models.ImageField(upload_to="avatars/", blank=True, null=True) is_active = models.BooleanField(default=True) is_staff = models.BooleanField(default=False) is_email_verified = models.BooleanField(default=False) timezone = models.CharField(max_length=50, default="UTC") stripe_customer_id = models.CharField(max_length=255, blank=True, null=True) USERNAME_FIELD = "email" REQUIRED_FIELDS = ["full_name"] objects = CustomUserManager() class Meta: db_table = "users_user" indexes = [ models.Index(fields=["email"]), models.Index(fields=["stripe_customer_id"]), ]
# apps/users/models.py class User(UUIDModel, AbstractBaseUser, PermissionsMixin): email = models.EmailField(unique=True) full_name = models.CharField(max_length=255, blank=True) avatar = models.ImageField(upload_to="avatars/", blank=True, null=True) is_active = models.BooleanField(default=True) is_staff = models.BooleanField(default=False) is_email_verified = models.BooleanField(default=False) timezone = models.CharField(max_length=50, default="UTC") stripe_customer_id = models.CharField(max_length=255, blank=True, null=True) USERNAME_FIELD = "email" REQUIRED_FIELDS = ["full_name"] objects = CustomUserManager() class Meta: db_table = "users_user" indexes = [ models.Index(fields=["email"]), models.Index(fields=["stripe_customer_id"]), ]
CustomUserManager переопределяет create_user и create_superuser под email-based аутентификацию. Поле stripe_customer_id на модели пользователя — это для случая, когда billing привязан к физическому лицу, а не к команде. В SaaS с командами обычно customer создаётся на Team, но иметь поле на обоих не вредно.
Дополнительно в этом app-е: EmailVerificationToken и PasswordResetToken — модели с полями token, expires_at, used_at. Никакого кэша или отдельного хранилища — просто строки в БД, что удобно при отладке и аудите.
teams
Здесь реализована мультиарендность. Цепочка владения:
textUser → TeamMembership → Team
User → TeamMembership → Team
TeamMembership — это through-таблица для M2M между User и Team, дополненная полем role. На Team хранятся лимиты (max_members, max_projects), которые заполняются при активации подписки из Plan. Это денормализация, но намеренная: проверка лимита при каждом действии не должна требовать JOIN через billing.
TeamInvitation — модель приглашений со статусами pending / accepted / expired / revoked. Приглашение — это запись в БД с токеном и TTL, а не просто ссылка в письме. Это позволяет отозвать приглашение, посмотреть список ожидающих и не бояться, что кто-то перехватит ссылку двухлетней давности.
Сигнал post_save на Team провизионирует Stripe customer при создании команды — команда сразу готова к биллингу, даже если подписку активируют позже.
billing
Четыре модели:
-
Plan— зеркалит Stripe Product + Prices. Редактируется через Django Admin. Содержитstripe_product_id,stripe_price_id_monthly,stripe_price_id_yearly, а также денормализованные лимиты:max_members,max_projects,has_api_access. -
Subscription— связьTeam(OneToOne) сPlanи всеми полями Stripe subscription: статус, период,cancel_at_period_end, trial. -
Invoice— запись каждого инвойса с ссылками на PDF и hosted URL от Stripe. -
WebhookEvent— журнал обработанных событий для идемпотентности.
notifications
EmailLog — аудит каждого исходящего письма: кому, что, когда, статус. Это избавляет от вопросов «а дошло ли письмо» в поддержке.
Шаблоны писем в apps/notifications/templates/notifications/ — HTML с базовым лейаутом и наследованием: welcome.html, verify_email.html, password_reset.html, invitation.html, billing_alert.html, subscription_cancelled.html.
api
Здесь нет бизнес-логики — только инфраструктура DRF:
-
router.py—DefaultRouterсо всеми зарегистрированными ViewSet-ами -
throttling.py—AnonRateThrottle,UserRateThrottle,BurstThrottle -
renderers.py—JSONRendererс конвертом{"data": ..., "meta": ...} -
exceptions.py— единый обработчик ошибок → единый JSON-формат ошибок -
pagination.py—CursorPaginationдля списков (более стабильная, чем page-based, при высокой частоте обновлений)
Все endpoint-ы с префиксом /api/v1/. Версионирование через NamespaceVersioning.
Интересные технические решения
1. Мультиарендность через RBAC
При проектировании мультиарендности есть несколько подходов. Самый простой — добавить tenant_id на каждую таблицу и фильтровать по нему в каждом QuerySet. Это работает, но требует дисциплины: легко забыть добавить фильтр в одном месте и случайно показать данные другого тенанта. Второй подход — отдельные схемы PostgreSQL для каждого тенанта (django-tenants). Это изоляция на уровне БД, но усложняет деплой, миграции и аналитику через несколько схем одновременно.
В Shipyard выбран средний путь: один набор таблиц, но с явной цепочкой владения через TeamMembership. Каждый ресурс приложения имеет FK на Team. Все ViewSet-ы фильтруют queryset по team_id из URL, и этот паттерн достаточно механический, чтобы его сложно было пропустить при code review.
Три роли и соответствующие DRF permissions:
python# apps/teams/permissions.pyfrom rest_framework.permissions import BasePermissionfrom apps.teams.models import TeamMembershipclass IsTeamMember(BasePermission): def has_permission(self, request, view): team_id = view.kwargs.get("team_id") return TeamMembership.objects.filter( team_id=team_id, user=request.user, ).exists()class IsTeamAdmin(BasePermission): def has_permission(self, request, view): team_id = view.kwargs.get("team_id") return TeamMembership.objects.filter( team_id=team_id, user=request.user, role__in=[TeamMembership.Role.ADMIN, TeamMembership.Role.OWNER], ).exists()class IsTeamOwner(BasePermission): def has_permission(self, request, view): team_id = view.kwargs.get("team_id") return TeamMembership.objects.filter( team_id=team_id, user=request.user, role=TeamMembership.Role.OWNER, ).exists()
# apps/teams/permissions.py from rest_framework.permissions import BasePermission from apps.teams.models import TeamMembership class IsTeamMember(BasePermission): def has_permission(self, request, view): team_id = view.kwargs.get("team_id") return TeamMembership.objects.filter( team_id=team_id, user=request.user, ).exists() class IsTeamAdmin(BasePermission): def has_permission(self, request, view): team_id = view.kwargs.get("team_id") return TeamMembership.objects.filter( team_id=team_id, user=request.user, role__in=[TeamMembership.Role.ADMIN, TeamMembership.Role.OWNER], ).exists() class IsTeamOwner(BasePermission): def has_permission(self, request, view): team_id = view.kwargs.get("team_id") return TeamMembership.objects.filter( team_id=team_id, user=request.user, role=TeamMembership.Role.OWNER, ).exists()
В ViewSet permissions выставляются по методу:
python# apps/teams/views.pyclass TeamMembershipViewSet(viewsets.ModelViewSet): def get_permissions(self): if self.action in ("update", "partial_update", "destroy"): return [IsAuthenticated(), IsTeamAdmin()] return [IsAuthenticated(), IsTeamMember()]
# apps/teams/views.py class TeamMembershipViewSet(viewsets.ModelViewSet): def get_permissions(self): if self.action in ("update", "partial_update", "destroy"): return [IsAuthenticated(), IsTeamAdmin()] return [IsAuthenticated(), IsTeamMember()]
Каждый запрос делает один дополнительный SELECT в teams_membership. Можно кэшировать через Redis с ключом membership:{user_id}:{team_id} и инвалидировать при изменении роли, но на старте это preoptimization — QuerySet с индексом по (team_id, user_id) справляется быстро.
Индекс (team, role) на TeamMembership помогает, когда начинают накапливаться тысячи membership-записей:
pythonclass Meta: db_table = "teams_membership" unique_together = [("team", "user")] indexes = [models.Index(fields=["team", "role"])]
class Meta: db_table = "teams_membership" unique_together = [("team", "user")] indexes = [models.Index(fields=["team", "role"])]
2. Stripe webhooks с идемпотентностью
Stripe официально гарантирует доставку «at least once» — это значит, что событие может прийти несколько раз. Кроме того, в production несколько воркеров Celery или Gunicorn-процессов могут получить одно событие параллельно через разные HTTP-запросы, если у вас горизонтальный деплой. Если обработчик не идемпотентен, можно активировать одну подписку дважды или выставить дублирующий инвойс.
Модель WebhookEvent:
python# apps/billing/models.pyclass WebhookEvent(TimestampedModel): stripe_event_id = models.CharField(max_length=255, unique=True, db_index=True) event_type = models.CharField(max_length=100) payload = models.JSONField() processed_at = models.DateTimeField(null=True, blank=True) error = models.TextField(blank=True) class Meta: db_table = "billing_webhook_event"
# apps/billing/models.py class WebhookEvent(TimestampedModel): stripe_event_id = models.CharField(max_length=255, unique=True, db_index=True) event_type = models.CharField(max_length=100) payload = models.JSONField() processed_at = models.DateTimeField(null=True, blank=True) error = models.TextField(blank=True) class Meta: db_table = "billing_webhook_event"
Логика обработки в webhooks.py:
python# apps/billing/webhooks.pyfrom django.utils import timezonefrom apps.billing.models import WebhookEventWEBHOOK_HANDLERS = { "checkout.session.completed": handle_checkout_completed, "customer.subscription.updated": handle_subscription_updated, "customer.subscription.deleted": handle_subscription_deleted, "invoice.payment_succeeded": handle_invoice_paid, "invoice.payment_failed": handle_invoice_payment_failed,}def process_webhook(event) -> None: obj, created = WebhookEvent.objects.get_or_create( stripe_event_id=event["id"], defaults={ "event_type": event["type"], "payload": event, }, ) if not created: # Событие уже обрабатывалось — пропускаем return handler = WEBHOOK_HANDLERS.get(event["type"]) if handler is None: return try: handler(event) obj.processed_at = timezone.now() obj.save(update_fields=["processed_at"]) except Exception as exc: obj.error = str(exc) obj.save(update_fields=["error"]) raise
# apps/billing/webhooks.py from django.utils import timezone from apps.billing.models import WebhookEvent WEBHOOK_HANDLERS = { "checkout.session.completed": handle_checkout_completed, "customer.subscription.updated": handle_subscription_updated, "customer.subscription.deleted": handle_subscription_deleted, "invoice.payment_succeeded": handle_invoice_paid, "invoice.payment_failed": handle_invoice_payment_failed, } def process_webhook(event) -> None: obj, created = WebhookEvent.objects.get_or_create( stripe_event_id=event["id"], defaults={ "event_type": event["type"], "payload": event, }, ) if not created: # Событие уже обрабатывалось — пропускаем return handler = WEBHOOK_HANDLERS.get(event["type"]) if handler is None: return try: handler(event) obj.processed_at = timezone.now() obj.save(update_fields=["processed_at"]) except Exception as exc: obj.error = str(exc) obj.save(update_fields=["error"]) raise
get_or_create по stripe_event_id — атомарная операция благодаря unique=True. Если два параллельных воркера получат одно событие одновременно, один из них получит IntegrityError и не пройдёт дальше. Это надёжнее, чем exists() + create(), где между двумя операциями есть гонка.
Отдельно: view для вебхуков проверяет Stripe signature перед тем, как парсить payload. Это делается через stripe.Webhook.construct_event с STRIPE_WEBHOOK_SECRET из переменной окружения:
python# apps/billing/views.pyimport stripefrom django.conf import settingsfrom rest_framework.views import APIViewfrom rest_framework.response import Responsefrom rest_framework import statusfrom apps.billing.webhooks import process_webhookclass StripeWebhookView(APIView): authentication_classes = [] permission_classes = [] def post(self, request): payload = request.body sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "") try: event = stripe.Webhook.construct_event( payload, sig_header, settings.STRIPE_WEBHOOK_SECRET ) except (ValueError, stripe.error.SignatureVerificationError): return Response(status=status.HTTP_400_BAD_REQUEST) process_webhook(event) return Response({"status": "ok"})
# apps/billing/views.py import stripe from django.conf import settings from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status from apps.billing.webhooks import process_webhook class StripeWebhookView(APIView): authentication_classes = [] permission_classes = [] def post(self, request): payload = request.body sig_header = request.META.get("HTTP_STRIPE_SIGNATURE", "") try: event = stripe.Webhook.construct_event( payload, sig_header, settings.STRIPE_WEBHOOK_SECRET ) except (ValueError, stripe.error.SignatureVerificationError): return Response(status=status.HTTP_400_BAD_REQUEST) process_webhook(event) return Response({"status": "ok"})
Без проверки сигнатуры любой может отправить произвольное событие на endpoint и, например, активировать чужую подписку.
3. Docker entrypoint с проверкой БД и условными миграциями
Типичная проблема с Docker Compose: web-контейнер стартует раньше, чем PostgreSQL успевает принять соединения. depends_on: db в Compose-файле гарантирует только то, что контейнер db запущен, но не то, что PostgreSQL внутри него готов принимать подключения. На медленной машине или при первом запуске с инициализацией тома Django успевает упасть с connection refused раньше, чем Postgres поднимется.
bash# docker/dev/entrypoint.sh#!/bin/shset -eecho "Waiting for PostgreSQL..."until pg_isready -h "$POSTGRES_HOST" -p "${POSTGRES_PORT:-5432}" -U "$POSTGRES_USER"; do sleep 1doneecho "PostgreSQL is ready."if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then echo "Running migrations..." python manage.py migrate --noinputfiif [ "${COLLECT_STATIC:-false}" = "true" ]; then python manage.py collectstatic --noinputfiexec "$@"
# docker/dev/entrypoint.sh #!/bin/sh set -e echo "Waiting for PostgreSQL..." until pg_isready -h "$POSTGRES_HOST" -p "${POSTGRES_PORT:-5432}" -U "$POSTGRES_USER"; do sleep 1 done echo "PostgreSQL is ready." if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then echo "Running migrations..." python manage.py migrate --noinput fi if [ "${COLLECT_STATIC:-false}" = "true" ]; then python manage.py collectstatic --noinput fi exec "$@"
pg_isready — утилита из пакета postgresql-client, добавленная в Dockerfile. Она пингует сервер без открытия full-connection, что быстрее и дешевле чем python manage.py check --database default в цикле. Цикл с sleep 1 вполне достаточен для dev — в production Compose обычно всё равно не используется.
Переменная RUN_MIGRATIONS позволяет отключить автомиграции. Это нужно, например, когда разворачиваешь несколько реплик приложения: только одна должна запускать migrate, остальные — стартовать сразу. Параллельный запуск migrate несколькими процессами обычно безопасен благодаря блокировкам в Django, но лучше не испытывать судьбу. В production это управляется через отдельный release command (например, в Heroku/Render) или init-контейнер.
Переменная COLLECT_STATIC аналогично: в dev-окружении статика раздаётся напрямую через runserver, в prod — через Nginx после collectstatic в CI.
4. Настройки, разделённые по окружениям
Стандартный паттерн, но важно сделать его правильно с самого начала.
textconfig/settings/├── base.py # всё общее├── development.py # DEBUG=True, console email backend├── production.py # Gunicorn, S3, Redis cache, Sentry, strict security└── testing.py # быстрый hasher, тестовая БД
config/settings/ ├── base.py # всё общее ├── development.py # DEBUG=True, console email backend ├── production.py # Gunicorn, S3, Redis cache, Sentry, strict security └── testing.py # быстрый hasher, тестовая БД
base.py содержит INSTALLED_APPS, MIDDLEWARE, конфиг DRF, Celery, базовые настройки без секретов. Секреты — только через os.environ, никаких fallback-значений в production-файле:
python# config/settings/base.pyimport osfrom pathlib import PathBASE_DIR = Path(__file__).resolve().parent.parent.parentSECRET_KEY = os.environ["SECRET_KEY"] # KeyError если не задан — это намеренноDATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": os.environ["POSTGRES_DB"], "USER": os.environ["POSTGRES_USER"], "PASSWORD": os.environ["POSTGRES_PASSWORD"], "HOST": os.environ.get("POSTGRES_HOST", "localhost"), "PORT": os.environ.get("POSTGRES_PORT", "5432"), }}
# config/settings/base.py import os from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent.parent SECRET_KEY = os.environ["SECRET_KEY"] # KeyError если не задан — это намеренно DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": os.environ["POSTGRES_DB"], "USER": os.environ["POSTGRES_USER"], "PASSWORD": os.environ["POSTGRES_PASSWORD"], "HOST": os.environ.get("POSTGRES_HOST", "localhost"), "PORT": os.environ.get("POSTGRES_PORT", "5432"), } }
python# config/settings/development.pyfrom .base import * # noqaDEBUG = TrueALLOWED_HOSTS = ["*"]EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"CELERY_TASK_ALWAYS_EAGER = True # задачи выполняются синхронно в devCELERY_TASK_EAGER_PROPAGATES = True
# config/settings/development.py from .base import # noqa DEBUG = True ALLOWED_HOSTS = [""] EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" CELERY_TASK_ALWAYS_EAGER = True # задачи выполняются синхронно в dev CELERY_TASK_EAGER_PROPAGATES = True
CELERY_TASK_ALWAYS_EAGER = True в dev — одно из самых удобных решений: задачи выполняются синхронно прямо в процессе Django, не нужен отдельный воркер для локальной разработки. Но важно выключить это в testing.py, иначе тесты на асинхронное поведение не будут работать честно.
production.py добавляет Sentry, S3-хранилище для медиафайлов, Redis как кэш-бэкенд, SECURE_HSTS_SECONDS, SESSION_COOKIE_SECURE и прочие security headers.
Что не вошло и почему
Покрытие тестами — отдельный раздел. В каждом app-е есть директория tests/ с test_models.py, test_views.py и factories.py (factory_boy). В tests/ на верхнем уровне — интеграционные тесты: test_auth_flow.py, test_team_flow.py, test_billing_flow.py. В tests/fixtures/stripe_webhook_events/ лежат JSON-файлы с реальными Stripe-событиями для тестирования webhook-обработчиков без моков. Конфигурация pytest и coverage — в pyproject.toml.
Frontend. Shipyard — это чистый API-backend. Никаких шаблонов, никакого HTMX, никакого React в репозитории. Выбор фронтенд-стека — это отдельное решение, и делать его за разработчика было бы самонадеянно. Next.js, Nuxt, SvelteKit, мобильное приложение — всё это подключается к тем же /api/v1/ endpoint-ам.
OAuth / социальная аутентификация. Код для этого есть (файлы adapters.py и pipeline.py в apps/users/), но не подключён по умолчанию. Причина простая: настройка OAuth-приложений в Google и GitHub требует реальных callback URL-ов, что неудобно при первом запуске. Добавить django-allauth в INSTALLED_APPS и прописать credentials в .env — 20 минут работы, когда это действительно нужно.
Kubernetes. Есть два Docker Compose файла: dev и prod. Kubernetes — это другой уровень сложности, который оправдан при определённом масштабе. На старте большинству SaaS хватает одного VPS с Docker Compose и правильно настроенным Nginx. Добавлять Helm charts в boilerplate значило бы усложнить точку входа без реальной пользы.
WebSocket / ASGI. asgi.py есть — переключиться на Daphne или Uvicorn несложно, если понадобится real-time. Но Celery + polling через REST решает большинство задач без усложнения деплоя.
Многоязычность. Директория locale/en/LC_MESSAGES/ в репозитории есть, django.po присутствует — Django i18n подключён, но переводы не заполнены. Это инфраструктура под будущее.
Результат
Запуск занимает пять команд:
bashgit clone https://github.com/EvgeniyMalykh/Shipyard.gitcd Shipyardcp .env.example .envdocker compose up --builddocker compose exec web python manage.py createsuperuser
git clone https://github.com/EvgeniyMalykh/Shipyard.git cd Shipyard cp .env.example .env docker compose up --build docker compose exec web python manage.py createsuperuser
После этого доступны:
|
Сервис |
URL |
|---|---|
|
Django API |
|
|
Django Admin |
|
|
Flower |
|
|
Health check |
Я выложил Shipyard на GitHub: github.com/EvgeniyMalykh/Shipyard. Там же лежит ARCHITECTURE.md на 2000+ строк — полный разбор каждого файла, схемы данных, flow аутентификации и Stripe-интеграции.
Ранний доступ с подробной документацией по настройке и деплою доступен на Gumroad. Это early access — буду рад фидбэку по архитектурным решениям, которые кажутся спорными или которые у вас сделаны иначе.
Если статья была полезна и вы делаете что-то похожее своими руками — напишите в комментариях, какие компромиссы вы сделали. В частности интересно, как другие решают вопрос с мультиарендностью: через отдельные схемы PostgreSQL, через tenant_id на каждой таблице или как-то иначе. Ну и по любым другим архитектурным решениям — тоже интересно услышать.
ссылка на оригинал статьи https://habr.com/ru/articles/1025002/