# Django Bootstrap

> Scaffold a Django/DRF project (Docker, split settings, JWT auth, Celery, S3) at a chosen blueprint tier.

- Skill: `theyoungastronauts/django-bootstrap` (Agent Skill)
- Install (CLI): `npx skillmds@latest add theyoungastronauts/django-bootstrap`
- Raw SKILL.md: https://api.skillmd.com/api/skills/theyoungastronauts/django-bootstrap/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: theyoungastronauts (https://skillmd.com/u/theyoungastronauts)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/theyoungastronauts/django-bootstrap

---


# Django Bootstrap

On-demand command for scaffolding a new Django/DRF project with Docker, PostgreSQL, split settings, and standard conventions. Supports three blueprint tiers for right-sized infrastructure.

## Before You Start

Ask the user for these values (provide defaults where shown):

| Placeholder | Description | Example |
|-------------|-------------|---------|
| `{blueprint}` | Blueprint tier: `minimal`, `standard`, or `full` | `standard` |
| `{service_name}` | Top-level directory name | `my_service` |
| `{project-name}` | Docker Compose project name (kebab-case) | `my-service` |
| `{db_name}` | Postgres database name (snake_case) | `my_service` |
| `{host_port}` | Host port for Django (default: `8000`) | `8002` |
| `{project_prefix}` | Short cache key prefix | `myserv` |
| `{heroku-app-name}` | Heroku app name (`full` only, if deploying) | `my-service-prod` |

### Blueprint Tiers

| Tier | Name | Includes | Use when |
|------|------|----------|----------|
| 1 | **minimal** | Django + DRF + Postgres + Docker. LocMem cache, session auth, AllowAny, local file storage, console logging. No Redis, Celery, JWT, custom User, S3, Sentry, email, discord. | Simple APIs, prototypes, no user accounts |
| 2 | **standard** | Minimal + Redis cache + custom User (`access` app) + JWT auth + Sentry. No Celery, S3, email, discord. | Most production apps with auth |
| 3 | **full** | Standard + Celery/Beat + S3/R2 + email + discord + Heroku deployment. | Background tasks, file uploads, notifications |

> **Only generate files and sections marked for your blueprint tier.** Sections are tagged `[ALL]`, `[STANDARD+]`, or `[FULL]`. Generate `[ALL]` always, `[STANDARD+]` for standard and full, `[FULL]` only for full.

> **MANDATORY: This project runs entirely in Docker.**
> Every Docker file below (Dockerfile.dev, docker-compose.yml, Makefile, docker/ scripts) MUST be generated.
> The Makefile is the sole interface — never run `python manage.py` directly on the host.

## Bootstrapping Steps

1. Create `{service_name}/` directory and all subdirectories
2. Generate all files from templates below, replacing placeholders (respecting blueprint tier tags)
3. `cd {service_name} && make build && make up`
4. Standard/full only: `make makemigrations` first — the shipped `access/` app has only `migrations/__init__.py`, and `migrate` fails with `Dependency on app with no migrations: access` without it
5. `make migrate && make createsuperuser`
6. Verify admin at `http://localhost:{host_port}/admin/`

---

## Directory Structure

### `[MINIMAL]` Directory Structure

```
{service_name}/
├── project/
│   ├── __init__.py
│   ├── models.py
│   ├── urls.py
│   ├── wsgi.py
│   ├── settings/
│   │   ├── __init__.py
│   │   ├── environment.py
│   │   ├── security.py
│   │   ├── apps.py
│   │   ├── database.py
│   │   ├── cache.py
│   │   ├── auth.py
│   │   ├── cors.py
│   │   ├── storage.py
│   │   └── logging.py
│   ├── utils/
│   │   └── __init__.py
│   └── templates/
├── docker/
│   ├── entrypoint.sh
│   └── wait-for-it.sh
├── docker-compose.yml
├── Dockerfile.dev
├── Makefile
├── Procfile
├── manage.py
├── requirements.txt
├── .python-version
├── .env.example
├── .gitignore
└── .coveragerc
```

### `[STANDARD]` Directory Structure — adds `access/` app and `sentry.py`

```
{service_name}/
├── project/
│   ├── __init__.py
│   ├── models.py
│   ├── urls.py
│   ├── wsgi.py
│   ├── settings/
│   │   ├── __init__.py
│   │   ├── environment.py
│   │   ├── security.py
│   │   ├── apps.py
│   │   ├── database.py
│   │   ├── cache.py
│   │   ├── auth.py
│   │   ├── cors.py
│   │   ├── storage.py
│   │   ├── logging.py
│   │   └── sentry.py
│   ├── utils/
│   │   └── __init__.py
│   └── templates/
├── access/
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── models.py
│   ├── migrations/
│   │   └── __init__.py
│   └── urls.py
├── docker/
│   ├── entrypoint.sh
│   └── wait-for-it.sh
├── docker-compose.yml
├── Dockerfile.dev
├── Makefile
├── Procfile
├── manage.py
├── requirements.txt
├── .python-version
├── .env.example
├── .gitignore
└── .coveragerc
```

### `[FULL]` Directory Structure — adds `worker.py`, `services/`, cloud settings

```
{service_name}/
├── project/
│   ├── __init__.py
│   ├── models.py
│   ├── urls.py
│   ├── worker.py
│   ├── wsgi.py
│   ├── settings/
│   │   ├── __init__.py
│   │   ├── environment.py
│   │   ├── security.py
│   │   ├── apps.py
│   │   ├── database.py
│   │   ├── cache.py
│   │   ├── auth.py
│   │   ├── cors.py
│   │   ├── aws.py
│   │   ├── storage.py
│   │   ├── worker.py
│   │   ├── email.py
│   │   ├── logging.py
│   │   └── sentry.py
│   ├── services/
│   │   ├── __init__.py
│   │   ├── storage.py
│   │   ├── email.py
│   │   └── discord.py
│   ├── utils/
│   │   └── __init__.py
│   └── templates/
├── access/
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── models.py
│   ├── migrations/
│   │   └── __init__.py
│   └── urls.py
├── docker/
│   ├── entrypoint.sh
│   └── wait-for-it.sh
├── docker-compose.yml
├── Dockerfile.dev
├── Makefile
├── Procfile
├── manage.py
├── requirements.txt
├── .python-version
├── .env.example
├── .gitignore
└── .coveragerc
```

---

## File Templates

### manage.py `[ALL]`

```python
#!/usr/bin/env python
import os
import sys


def main():
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed and "
            "available on your PYTHONPATH environment variable? Did you "
            "forget to activate a virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)


if __name__ == "__main__":
    main()
```

### project/\_\_init\_\_.py — varies by tier

**`[MINIMAL]` and `[STANDARD]`:** empty file

```python
```

**`[FULL]`:**

```python
from .worker import app as celery_app

__all__ = ("celery_app",)
```

### project/models.py `[ALL]`

```python
import uuid

from django.core.exceptions import ValidationError
from django.db import models
from django.utils.deconstruct import deconstructible


@deconstructible
class TypeValidator:
    """Validator to ensure a field value is of a specific type."""

    def __init__(self, expected_type):
        self.expected_type = expected_type

    def __call__(self, value):
        if not isinstance(value, self.expected_type):
            raise ValidationError(
                f"Value must be of type {self.expected_type.__name__}, "
                f"got {type(value).__name__}"
            )

    def __eq__(self, other):
        return (
            isinstance(other, TypeValidator)
            and self.expected_type == other.expected_type
        )


class AbstractModel(models.Model):
    uuid = models.UUIDField(
        default=uuid.uuid4,
        unique=True,
        editable=False,
        db_index=True,
    )
    metadata = models.JSONField(
        default=dict,
        blank=True,
        null=True,
        validators=[TypeValidator(dict)],
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    @classmethod
    def get_field(cls, field_name):
        return cls._meta.get_field(field_name)

    def silent_save(self, *fields):
        """Update specific fields without triggering updated_at or signals."""
        cls = type(self)
        if hasattr(cls, "_default_manager"):
            cls._default_manager.filter(pk=self.pk).update(
                **{field: getattr(self, field) for field in fields}
            )

    class Meta:
        abstract = True
        ordering = ["-created_at"]
```

### project/worker.py `[FULL]`

```python
import os

from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")

app = Celery("project")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()

# Beat schedule — add periodic tasks here
app.conf.beat_schedule = {}


@app.task(bind=True, ignore_result=True)
def debug_task(self):
    print(f"Request: {self.request!r}")
```

### project/wsgi.py `[ALL]`

```python
import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings")

application = get_wsgi_application()
```

### project/urls.py `[ALL]`

```python
from django.contrib import admin
from django.urls import include, path

# [STANDARD+] auth app (access) — omit this import and the auth include for the
# minimal (no-auth) tier
from access.urls import urlpatterns as auth_urlpatterns

urlpatterns = [
    path("admin/", admin.site.urls),
    # API v1
    path("api/v1/auth/", include((auth_urlpatterns, "auth"))),  # [STANDARD+]
]
```

---

## Settings

### project/settings/\_\_init\_\_.py — varies by tier

**`[MINIMAL]`** (9 imports):

```python
# Environment and core settings
from .environment import *  # noqa: F401, F403

# Security, middleware, templates
from .security import *  # noqa: F401, F403

# Installed apps
from .apps import *  # noqa: F401, F403

# Database
from .database import *  # noqa: F401, F403

# Cache
from .cache import *  # noqa: F401, F403

# Authentication and REST framework
from .auth import *  # noqa: F401, F403

# CORS
from .cors import *  # noqa: F401, F403

# Storage
from .storage import *  # noqa: F401, F403

# Logging
from .logging import *  # noqa: F401, F403
```

**`[STANDARD]`** (10 imports — adds sentry):

```python
# Environment and core settings
from .environment import *  # noqa: F401, F403

# Security, middleware, templates
from .security import *  # noqa: F401, F403

# Installed apps
from .apps import *  # noqa: F401, F403

# Database
from .database import *  # noqa: F401, F403

# Cache
from .cache import *  # noqa: F401, F403

# Authentication and REST framework
from .auth import *  # noqa: F401, F403

# CORS
from .cors import *  # noqa: F401, F403

# Storage
from .storage import *  # noqa: F401, F403

# Logging
from .logging import *  # noqa: F401, F403

# Sentry (Error Tracking)
from .sentry import *  # noqa: F401, F403
```

**`[FULL]`** (13 imports — adds aws, worker, email, sentry):

```python
# Environment and core settings
from .environment import *  # noqa: F401, F403

# Security, middleware, templates
from .security import *  # noqa: F401, F403

# Installed apps
from .apps import *  # noqa: F401, F403

# Database
from .database import *  # noqa: F401, F403

# Cache
from .cache import *  # noqa: F401, F403

# Authentication and REST framework
from .auth import *  # noqa: F401, F403

# CORS
from .cors import *  # noqa: F401, F403

# AWS/S3/R2
from .aws import *  # noqa: F401, F403

# Storage
from .storage import *  # noqa: F401, F403

# Celery worker
from .worker import *  # noqa: F401, F403

# Email
from .email import *  # noqa: F401, F403

# Logging
from .logging import *  # noqa: F401, F403

# Sentry (Error Tracking)
from .sentry import *  # noqa: F401, F403
```

### project/settings/environment.py `[ALL]`

```python
import os
from pathlib import Path

import environ

BASE_DIR = Path(__file__).resolve().parent.parent.parent

ENV = environ.Env(
    DEBUG=(bool, False),
)

env_file = BASE_DIR / ".env"
if env_file.exists():
    ENV.read_env(str(env_file))

DEBUG = ENV.bool("DEBUG", default=False)
ENVIRONMENT = ENV.str("ENVIRONMENT", default="development")
VERSION = ENV.str("VERSION", default="0.1.0")

FRONTEND_BASE_URL = ENV.str("FRONTEND_BASE_URL", default="http://localhost:3000")
```

### project/settings/security.py `[ALL]`

```python
from .environment import BASE_DIR, ENV, DEBUG

SECRET_KEY = ENV.str("SECRET_KEY", default="django-insecure-change-me-in-production")

ALLOWED_HOSTS = ENV.list("ALLOWED_HOSTS", default=["localhost", "127.0.0.1"])

CSRF_TRUSTED_ORIGINS = ENV.list("CSRF_TRUSTED_ORIGINS", default=["http://localhost:{host_port}"])

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

ROOT_URLCONF = "project.urls"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "project" / "templates"],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

WSGI_APPLICATION = "project.wsgi.application"

AUTH_PASSWORD_VALIDATORS = [
    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
    {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]

LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True

STATIC_URL = "static/"
STATIC_ROOT = "staticfiles"

DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

# Production hardening
if not DEBUG:
    SECURE_BROWSER_XSS_FILTER = True
    SECURE_CONTENT_TYPE_NOSNIFF = True
    X_FRAME_OPTIONS = "DENY"
    SECURE_SSL_REDIRECT = True
    SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
```

### project/settings/apps.py — varies by tier

**`[MINIMAL]`:**

```python
DJANGO_APPS = [
    "admin_interface",
    "colorfield",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
]

THIRD_PARTY_APPS = [
    "rest_framework",
    "corsheaders",
    "admin_auto_filters",
]

LOCAL_APPS = [
    "project",
]

INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS

# Required for django-admin-interface
X_FRAME_OPTIONS = "SAMEORIGIN"
SILENCED_SYSTEM_CHECKS = ["security.W019"]
```

Note: `admin_interface` and `colorfield` must come before `django.contrib.admin`.

**`[STANDARD+]`** (adds simplejwt, token_blacklist, access):

```python
DJANGO_APPS = [
    "admin_interface",
    "colorfield",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
]

THIRD_PARTY_APPS = [
    "rest_framework",
    "rest_framework_simplejwt",
    "rest_framework_simplejwt.token_blacklist",
    "corsheaders",
    "admin_auto_filters",
    "storages",
]

LOCAL_APPS = [
    "project",
    "access",
]

INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS

# Required for django-admin-interface
X_FRAME_OPTIONS = "SAMEORIGIN"
SILENCED_SYSTEM_CHECKS = ["security.W019"]
```

Note: `admin_interface` and `colorfield` must come before `django.contrib.admin`.

### project/settings/database.py `[ALL]`

```python
import dj_database_url

from .environment import ENV, DEBUG

DATABASE_URL = ENV.str(
    "DATABASE_URL",
    default="postgres://postgres:postgres@db:5432/{db_name}"
)

DATABASES = {
    "default": dj_database_url.config(
        default=DATABASE_URL,
        conn_max_age=600,
        conn_health_checks=True,
        ssl_require=not DEBUG,
    )
}
```

### project/settings/cache.py — varies by tier

**`[MINIMAL]`** (LocMemCache, database sessions):

```python
CACHE_TIMEOUT_SHORT = 5
CACHE_TIMEOUT_MEDIUM = 30
CACHE_TIMEOUT_DEFAULT = 60
CACHE_TIMEOUT_LONG = 300

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
        "KEY_PREFIX": "{project_prefix}",
        "VERSION": 1,
        "TIMEOUT": CACHE_TIMEOUT_DEFAULT,
    },
}

SESSION_ENGINE = "django.contrib.sessions.backends.db"
```

**`[STANDARD+]`** (Redis, cache sessions):

```python
import ssl

from .environment import ENV

REDIS_URL = ENV.str("REDIS_URL", default="redis://redis:6379/0")

CACHE_TIMEOUT_SHORT = 5
CACHE_TIMEOUT_MEDIUM = 30
CACHE_TIMEOUT_DEFAULT = 60
CACHE_TIMEOUT_LONG = 300

REDIS_SSL = REDIS_URL.startswith("rediss://")

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": REDIS_URL,
        "KEY_PREFIX": "{project_prefix}",
        "VERSION": 1,
        "TIMEOUT": CACHE_TIMEOUT_DEFAULT,
        # Heroku Redis (and some managed providers) serve rediss:// with a
        # self-signed cert, so verification is disabled here. This trades away
        # MITM protection and is only acceptable on a trusted private network —
        # prefer ssl.CERT_REQUIRED with the provider's CA bundle where supported.
        **({"OPTIONS": {"ssl_cert_reqs": ssl.CERT_NONE}} if REDIS_SSL else {}),
    },
}

SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
```

### project/settings/auth.py — varies by tier

**`[MINIMAL]`** (session auth, AllowAny):

```python
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.AllowAny",
    ],
    "DEFAULT_RENDERER_CLASSES": [
        "rest_framework.renderers.JSONRenderer",
    ],
    "DEFAULT_PARSER_CLASSES": [
        "rest_framework.parsers.JSONParser",
    ],
    "DEFAULT_PAGINATION_CLASS": "project.pagination.StandardResultsSetPagination",
    "PAGE_SIZE": 20,
}
```

**`[STANDARD+]`** (JWT + session, IsAuthenticated, custom user):

```python
from datetime import timedelta

from .environment import ENV

AUTH_USER_MODEL = "access.User"

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    "DEFAULT_RENDERER_CLASSES": [
        "rest_framework.renderers.JSONRenderer",
    ],
    "DEFAULT_PARSER_CLASSES": [
        "rest_framework.parsers.JSONParser",
    ],
    "DEFAULT_PAGINATION_CLASS": "project.pagination.StandardResultsSetPagination",
    "PAGE_SIZE": 20,
}

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(
        minutes=ENV.int("JWT_ACCESS_TOKEN_LIFETIME_MINUTES", default=480)
    ),
    "REFRESH_TOKEN_LIFETIME": timedelta(
        days=ENV.int("JWT_REFRESH_TOKEN_LIFETIME_DAYS", default=7)
    ),
    # Rotation off: the contract's refresh response is `{access}` only, and the
    # decoupled frontend keeps its original refresh token across calls. Stock
    # TokenRefreshView returns a new `refresh` in the body when rotation is on,
    # which would silently diverge from both. Turn rotation on only if the
    # frontend is updated to persist the rotated refresh token on every call.
    "ROTATE_REFRESH_TOKENS": False,
    "UPDATE_LAST_LOGIN": True,
    "ALGORITHM": "HS256",
    "AUTH_HEADER_TYPES": ("Bearer",),
    "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
    # uuid, not the integer PK — a JWT's claims are base64, not encrypted, so
    # the identity field is client-visible. Keeps the contract's "clients only
    # ever see uuid" true for tokens too, not just URLs and payloads.
    "USER_ID_FIELD": "uuid",
    "USER_ID_CLAIM": "user_id",
}
```

### project/pagination.py `[ALL]`

Custom paginator emitting the canonical contract shape (`{page, count, num_pages, results}`) with `page`/`page_size` query params. Wired in above as `DEFAULT_PAGINATION_CLASS`, so every DRF list endpoint paginates in this shape automatically.

```python
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response


class StandardResultsSetPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = "page_size"
    max_page_size = 100

    def get_paginated_response(self, data):
        return Response(
            {
                "page": self.page.number,
                "count": self.page.paginator.count,
                "num_pages": self.page.paginator.num_pages,
                "results": data,
            }
        )
```

### project/settings/cors.py `[ALL]`

```python
from .environment import ENV, DEBUG, FRONTEND_BASE_URL

CORS_ALLOWED_ORIGINS = ENV.list(
    "CORS_ALLOWED_ORIGINS",
    default=[FRONTEND_BASE_URL]
)

CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = DEBUG

CORS_ALLOW_HEADERS = [
    "accept",
    "accept-encoding",
    "authorization",
    "content-type",
    "dnt",
    "origin",
    "user-agent",
    "x-csrftoken",
    "x-requested-with",
]
```

### project/settings/aws.py `[FULL]`

```python
from .environment import ENV

# Cloudflare R2 configuration (S3-compatible)
R2_ACCOUNT_ID = ENV.str("R2_ACCOUNT_ID", default="")
R2_ACCESS_KEY_ID = ENV.str("R2_ACCESS_KEY_ID", default="")
R2_SECRET_ACCESS_KEY = ENV.str("R2_SECRET_ACCESS_KEY", default="")
R2_BUCKET_NAME = ENV.str("R2_BUCKET_NAME", default="")
R2_CUSTOM_DOMAIN = ENV.str("R2_CUSTOM_DOMAIN", default="")

R2_ENDPOINT_URL = f"https://{R2_ACCOUNT_ID}.r2.cloudflarestorage.com"

# AWS-compatible settings (for django-storages)
AWS_ACCESS_KEY_ID = R2_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY = R2_SECRET_ACCESS_KEY
AWS_STORAGE_BUCKET_NAME = R2_BUCKET_NAME
AWS_S3_REGION_NAME = "auto"
AWS_S3_ENDPOINT_URL = R2_ENDPOINT_URL
AWS_S3_SIGNATURE_VERSION = "s3v4"

AWS_DEFAULT_ACL = None
# Public bucket served through R2_CUSTOM_DOMAIN, so object URLs are unsigned —
# this matches querystring_auth=False in the [FULL] STORAGES block. For a
# private bucket, set this True and hand out time-limited links via
# FileService.get_signed_url instead. AWS_QUERYSTRING_EXPIRE applies only when
# signing is on.
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
AWS_QUERYSTRING_EXPIRE = 3600

AWS_S3_CUSTOM_DOMAIN = R2_CUSTOM_DOMAIN
USE_R2_STORAGE = ENV.bool("USE_R2_STORAGE", default=False)
```

### project/settings/storage.py — varies by tier

**`[MINIMAL]` and `[STANDARD]`** (local filesystem only):

```python
import os

from .environment import BASE_DIR

TEMP_DIR = os.path.join(BASE_DIR, "tmp")
os.makedirs(TEMP_DIR, exist_ok=True)

STORAGES = {
    "default": {
        "BACKEND": "django.core.files.storage.FileSystemStorage",
    },
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
    },
}
MEDIA_URL = "/media/"
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
os.makedirs(MEDIA_ROOT, exist_ok=True)
```

**`[FULL]`** (R2 conditional):

```python
import os

from .environment import BASE_DIR
from .aws import (
    R2_ACCESS_KEY_ID,
    R2_SECRET_ACCESS_KEY,
    R2_BUCKET_NAME,
    R2_ENDPOINT_URL,
    R2_CUSTOM_DOMAIN,
    USE_R2_STORAGE,
)

TEMP_DIR = os.path.join(BASE_DIR, "tmp")
os.makedirs(TEMP_DIR, exist_ok=True)

if USE_R2_STORAGE and R2_ACCESS_KEY_ID:
    STORAGES = {
        "default": {
            "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
            "OPTIONS": {
                "access_key": R2_ACCESS_KEY_ID,
                "secret_key": R2_SECRET_ACCESS_KEY,
                "bucket_name": R2_BUCKET_NAME,
                "region_name": "auto",
                "endpoint_url": R2_ENDPOINT_URL,
                "signature_version": "s3v4",
                "default_acl": None,
                "querystring_auth": False,
                "custom_domain": R2_CUSTOM_DOMAIN,
                "file_overwrite": False,
            },
        },
        "staticfiles": {
            "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
        },
    }
    MEDIA_URL = f"https://{R2_CUSTOM_DOMAIN}/"
else:
    STORAGES = {
        "default": {
            "BACKEND": "django.core.files.storage.FileSystemStorage",
        },
        "staticfiles": {
            "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
        },
    }
    MEDIA_URL = "/media/"
    MEDIA_ROOT = os.path.join(BASE_DIR, "media")
    os.makedirs(MEDIA_ROOT, exist_ok=True)
```

### project/settings/worker.py `[FULL]`

```python
import ssl

from .environment import ENV
from .cache import REDIS_URL, REDIS_SSL

CELERY_BROKER_URL = ENV.str("CELERY_BROKER_URL", default=REDIS_URL)
CELERY_RESULT_BACKEND = ENV.str("CELERY_RESULT_BACKEND", default=None)

if REDIS_SSL:
    CELERY_BROKER_USE_SSL = {"ssl_cert_reqs": ssl.CERT_NONE}
    CELERY_REDIS_BACKEND_USE_SSL = {"ssl_cert_reqs": ssl.CERT_NONE}

CELERY_ACCEPT_CONTENT = ["application/json"]
CELERY_RESULT_SERIALIZER = "json"
CELERY_TASK_SERIALIZER = "json"

CELERY_TASK_ANNOTATIONS = {
    "*": {
        "max_retries": 5,
        "retry_backoff": True,
    }
}

CELERY_TIMEZONE = "UTC"
CELERY_ENABLE_UTC = True
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 30 * 60  # 30 minutes

PROCESS_TASKS_ASYNC = ENV.bool("PROCESS_TASKS_ASYNC", default=True)
```

### project/settings/email.py `[FULL]`

```python
from .environment import ENV

EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = ENV.str("EMAIL_HOST", default="")
EMAIL_PORT = ENV.int("EMAIL_PORT", default=587)
EMAIL_USE_TLS = ENV.bool("EMAIL_USE_TLS", default=True)
EMAIL_HOST_USER = ENV.str("EMAIL_HOST_USER", default="")
EMAIL_HOST_PASSWORD = ENV.str("EMAIL_HOST_PASSWORD", default="")
DEFAULT_FROM_EMAIL = ENV.str("DEFAULT_FROM_EMAIL", default="noreply@example.com")

PASSWORD_RESET_TIMEOUT = 3600
```

### project/settings/logging.py — varies by tier

**`[MINIMAL]` and `[STANDARD]`** (no celery logger):

```python
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "verbose": {
            "format": "{levelname} {asctime} {module} {message}",
            "style": "{",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "verbose",
        },
    },
    "root": {
        "handlers": ["console"],
        "level": "INFO",
    },
    "loggers": {
        "django": {"handlers": ["console"], "level": "INFO", "propagate": False},
    },
}
```

**`[FULL]`** (adds celery logger):

```python
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "verbose": {
            "format": "{levelname} {asctime} {module} {message}",
            "style": "{",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "verbose",
        },
    },
    "root": {
        "handlers": ["console"],
        "level": "INFO",
    },
    "loggers": {
        "django": {"handlers": ["console"], "level": "INFO", "propagate": False},
        "celery": {"handlers": ["console"], "level": "INFO", "propagate": False},
    },
}
```

### project/settings/sentry.py — `[STANDARD+]`, varies by tier

**`[STANDARD]`** (Django + Logging integrations only):

```python
import logging
from .environment import ENV, ENVIRONMENT

SENTRY_DSN = ENV.str("SENTRY_DSN", default="")

if SENTRY_DSN:
    import sentry_sdk
    from sentry_sdk.integrations.django import DjangoIntegration
    from sentry_sdk.integrations.logging import LoggingIntegration

    sentry_sdk.init(
        dsn=SENTRY_DSN,
        environment=ENVIRONMENT,
        integrations=[
            DjangoIntegration(),
            LoggingIntegration(
                level=logging.INFO,
                event_level=logging.ERROR,
            ),
        ],
        send_default_pii=False,
    )
```

**`[FULL]`** (adds Celery + Redis integrations):

```python
import logging
from .environment import ENV, ENVIRONMENT

SENTRY_DSN = ENV.str("SENTRY_DSN", default="")

if SENTRY_DSN:
    import sentry_sdk
    from sentry_sdk.integrations.django import DjangoIntegration
    from sentry_sdk.integrations.celery import CeleryIntegration
    from sentry_sdk.integrations.redis import RedisIntegration
    from sentry_sdk.integrations.logging import LoggingIntegration

    sentry_sdk.init(
        dsn=SENTRY_DSN,
        environment=ENVIRONMENT,
        integrations=[
            DjangoIntegration(),
            CeleryIntegration(),
            RedisIntegration(),
            LoggingIntegration(
                level=logging.INFO,
                event_level=logging.ERROR,
            ),
        ],
        send_default_pii=False,
    )
```

---

## Services `[FULL]`

### project/services/\_\_init\_\_.py

```python
```

### project/services/storage.py

```python
import boto3
from botocore.config import Config
from django.conf import settings


class R2Client:
    _client = None

    @classmethod
    def get_client(cls):
        if cls._client is None:
            cls._client = boto3.client(
                "s3",
                endpoint_url=settings.R2_ENDPOINT_URL,
                aws_access_key_id=settings.R2_ACCESS_KEY_ID,
                aws_secret_access_key=settings.R2_SECRET_ACCESS_KEY,
                config=Config(signature_version="s3v4"),
                region_name="auto",
            )
        return cls._client

    @classmethod
    def get_signed_url(cls, key: str, expires_in: int = 3600) -> str:
        client = cls.get_client()
        return client.generate_presigned_url(
            "get_object",
            Params={"Bucket": settings.R2_BUCKET_NAME, "Key": key},
            ExpiresIn=expires_in,
        )

    @classmethod
    def get_public_url(cls, key: str) -> str:
        return f"https://{settings.R2_CUSTOM_DOMAIN}/{key}"

    @classmethod
    def upload_file(cls, file_data: bytes, key: str, content_type: str = "audio/mpeg"):
        client = cls.get_client()
        client.put_object(
            Bucket=settings.R2_BUCKET_NAME,
            Key=key,
            Body=file_data,
            ContentType=content_type,
        )

    @classmethod
    def delete_file(cls, key: str):
        client = cls.get_client()
        client.delete_object(Bucket=settings.R2_BUCKET_NAME, Key=key)

    @classmethod
    def file_exists(cls, key: str) -> bool:
        client = cls.get_client()
        try:
            client.head_object(Bucket=settings.R2_BUCKET_NAME, Key=key)
            return True
        except client.exceptions.ClientError:
            return False
```

### project/services/email.py

```python
from enum import Enum

from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string


class EmailType(Enum):
    TRANSACTIONAL = "transactional"
    USER_TRIGGERED = "user_triggered"
    MARKETING = "marketing"


class EmailService:
    @classmethod
    def render_template(cls, template_name, context) -> str:
        context.setdefault("frontend_url", settings.FRONTEND_BASE_URL)
        return render_to_string(template_name, context)

    @classmethod
    def should_send(cls, user, email_type: EmailType) -> bool:
        if email_type == EmailType.TRANSACTIONAL:
            return True
        return getattr(user, "email_notifications", True)

    @classmethod
    def send(cls, user, email_type, subject, message, html_message=None) -> bool:
        if not cls.should_send(user, email_type):
            return False
        # fail_silently keeps a delivery failure from raising, but send_mail
        # returns the number of messages actually delivered — return that so the
        # caller can tell a real send from a silently-swallowed failure.
        sent = send_mail(
            subject=subject,
            message=message,
            from_email=settings.DEFAULT_FROM_EMAIL,
            recipient_list=[user.email],
            html_message=html_message,
            fail_silently=True,
        )
        return sent > 0
```

### project/services/discord.py

```python
import logging
from enum import Enum

import requests
from django.conf import settings

logger = logging.getLogger(__name__)


class DiscordColor(Enum):
    GREEN = 0x2ECC71
    BLUE = 0x3498DB
    ORANGE = 0xE67E22
    RED = 0xE74C3C


class DiscordService:
    @classmethod
    def send_embed(cls, webhook_url, title, description, color, fields=None) -> bool:
        if not webhook_url:
            return False
        try:
            embed = {
                "title": title,
                "description": description,
                "color": color.value,
            }
            if fields:
                embed["fields"] = [
                    {"name": k, "value": str(v), "inline": True}
                    for k, v in fields.items()
                ]
            payload = {"embeds": [embed]}
            requests.post(webhook_url, json=payload, timeout=10)
            return True
        except Exception as e:
            logger.warning(f"Discord webhook failed: {e}")
            return False

    @classmethod
    def notify_signup(cls, email, name=None):
        cls.send_embed(
            getattr(settings, "DISCORD_WEBHOOK_SIGNUPS", ""),
            "New Signup",
            f"**{name or email}** just signed up",
            DiscordColor.GREEN,
        )
```

---

## Custom User Model `[STANDARD+]`

### access/models.py

```python
import uuid

from django.contrib.auth.models import AbstractUser
from django.db import models


class User(AbstractUser):
    uuid = models.UUIDField(
        default=uuid.uuid4,
        unique=True,
        editable=False,
        db_index=True,
    )
    # Unique so the contract's email-based login (/api/v1/auth/login/) can
    # resolve a single account. Public identifier stays `uuid`, not the PK.
    email = models.EmailField(unique=True)
    metadata = models.JSONField(default=dict, blank=True, null=True)

    class Meta:
        ordering = ["-date_joined"]

    def __str__(self):
        return self.email or self.username
```

### access/admin.py

```python
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin

from .models import User


@admin.register(User)
class UserAdmin(BaseUserAdmin):
    list_display = ["email", "username", "is_active", "is_staff", "date_joined"]
    list_filter = ["is_active", "is_staff", "date_joined"]
    search_fields = ["email", "username"]
    ordering = ["-date_joined"]
```

### access/apps.py

```python
from django.apps import AppConfig


class AccessConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "access"
```

### access/serializers.py

```python
from django.contrib.auth import get_user_model
from rest_framework import serializers
from rest_framework_simplejwt.tokens import RefreshToken

User = get_user_model()


def tokens_for(user) -> dict:
    refresh = RefreshToken.for_user(user)
    return {"access": str(refresh.access_token), "refresh": str(refresh)}


class UserSerializer(serializers.ModelSerializer):
    # Public identifier is uuid — the integer PK is never exposed.
    class Meta:
        model = User
        fields = ["uuid", "username", "email", "first_name", "last_name"]
        read_only_fields = ["uuid"]


class RegisterSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True, min_length=8)

    class Meta:
        model = User
        fields = ["username", "email", "password", "first_name", "last_name"]

    def create(self, validated_data):
        password = validated_data.pop("password")
        user = User(**validated_data)
        user.set_password(password)
        user.save()
        return user


class LoginSerializer(serializers.Serializer):
    email = serializers.EmailField()
    password = serializers.CharField(write_only=True)

    def validate(self, attrs):
        user = User.objects.filter(email=attrs["email"]).first()
        if user is None or not user.check_password(attrs["password"]):
            raise serializers.ValidationError("Invalid email or password.")
        if not user.is_active:
            raise serializers.ValidationError("Account is disabled.")
        attrs["user"] = user
        return attrs
```

### access/views.py

```python
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from .serializers import (
    LoginSerializer,
    RegisterSerializer,
    UserSerializer,
    tokens_for,
)


class RegisterView(APIView):
    permission_classes = [AllowAny]

    def post(self, request):
        serializer = RegisterSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        user = serializer.save()
        return Response(
            {"user": UserSerializer(user).data, "tokens": tokens_for(user)},
            status=status.HTTP_201_CREATED,
        )


class LoginView(APIView):
    permission_classes = [AllowAny]

    def post(self, request):
        serializer = LoginSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        return Response(tokens_for(serializer.validated_data["user"]))


class MeView(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        return Response(UserSerializer(request.user).data)
```

### access/urls.py

Ships the contract's auth endpoints. Refresh reuses SimpleJWT's `TokenRefreshView` (returns `{access}`).

```python
from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView

from . import views

urlpatterns = [
    path("login/", views.LoginView.as_view(), name="login"),
    path("refresh/", TokenRefreshView.as_view(), name="refresh"),
    path("me/", views.MeView.as_view(), name="me"),
    path("register/", views.RegisterView.as_view(), name="register"),
]
```

---

## Docker (REQUIRED — do not skip)

### docker-compose.yml — varies by tier

**`[MINIMAL]`** (web + db):

```yaml
name: {project-name}

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    command: ["/wait-for-it.sh", "db", "--", "/entrypoint.sh"]
    volumes:
      - .:/app
    ports:
      - "{host_port}:8000"
    depends_on:
      - db
    env_file:
      - .env
    environment:
      - DEBUG=True
      - DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}

  db:
    image: postgres:17
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB={db_name}
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres

volumes:
  postgres_data:
```

**`[STANDARD]`** (web + db + redis):

```yaml
name: {project-name}

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    command: ["/wait-for-it.sh", "db", "--", "/entrypoint.sh"]
    volumes:
      - .:/app
    ports:
      - "{host_port}:8000"
    depends_on:
      - db
      - redis
    env_file:
      - .env
    environment:
      - DEBUG=True
      - DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}

  db:
    image: postgres:17
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB={db_name}
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres

  redis:
    image: redis:7
    volumes:
      - redis_data:/data

volumes:
  postgres_data:
  redis_data:
```

**`[FULL]`** (web + db + redis + celery + celery-beat):

```yaml
name: {project-name}

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    command: ["/wait-for-it.sh", "db", "--", "/entrypoint.sh"]
    volumes:
      - .:/app
    ports:
      - "{host_port}:8000"
    depends_on:
      - db
      - redis
    env_file:
      - .env
    environment:
      - DEBUG=True
      - DATABASE_URL=postgres://postgres:postgres@db:5432/{db_name}

  db:
    image: postgres:17
    volumes:
      - postgres_data:/var/lib/postgresql/data
    environ

…(truncated)
