Skip to content

Set up a Django Project Locally

This guide explains how to set up a Django project and local environment on macOS to prepare for future cloud deployment. It uses PostgreSQL for both models and caching to mirror the production setup.

NOTE

You can follow the steps in this guide as written, but replace the following placeholders with your own names:

  • my-project: Your package name
  • my_project: Your Django project name
  • users: Your Django app name for custom user model
  • my_db_user: Your Postgres database user name
  • my_db_password: Your Postgres database password
  • my_database: Your Postgres database name
  • my_logger: Your logger name
  • prod-public-ip: Your production server's public IP address
  • example.com: Your domain name (including Example.Com)
  • pirate_django_cache: Your cache table name in PostgreSQL database
  • pirate_token: Your Http-Only cookie name in browser

You should also uncomment any super-admin related lines and configure them as needed.

Create a New Project

First, install the uv package manager with this command:

shell
curl -LsSf https://astral.sh/uv/install.sh | sh

WARNING

Ensure your local Python version matches your cloud server's version to avoid issues with mod_wsgi. As of April 2026, the recommended version is Python 3.13.

Initialize your project and navigate into the directory:

shell
uv init my-project && cd my-project

Install Dependencies

Run the following commands to add the necessary packages for the new project:

shell
# Standard Django and utility packages
uv add django django-filter django-cors-headers django-redis django-storages 

# Database, environment, and other packages
uv add python-dotenv boto3 requests psycopg2-binary pillow 'qrcode[pil]' pyotp

# Use uuid6 for uuid7 support (ignore if using Python 3.14+)
uv add uuid6

# Device and user-agent tracking
uv add pyyaml ua-parser user-agents django-user-agents

# Needed to serve S3 files via CloudFront (recommended)
uv add cryptography

# Django Rest Framework and JWT
uv add djangorestframework djangorestframework-simplejwt drf-spectacular

Initiate a new Django project:

shell
django-admin startproject my_project .

Set Up a Local Database

Install Postgres database locally using the instructions from this link.

Once installed, add the Postgres binaries to your system path:

shell
export PATH="/Library/PostgreSQL/18/bin/:$PATH"

Access the Postgres terminal:

shell
psql -U postgres

Run these SQL commands to create your database and a dedicated user:

sql
-- Replace my_db_password with a strong password of your choice
CREATE USER my_db_user ENCRYPTED PASSWORD 'my_db_password';
CREATE DATABASE my_database WITH OWNER=my_db_user;

Configure the Environment

Create a new .env file in your project root to store sensitive credentials and environment-specific settings:

txt
DJ_ENV=TEST
SECRET_KEY="your-secret-key-here"

POSTGRES_DATABASE=my_database
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=my_db_user
POSTGRES_PASSWORD=my_db_password

EMAIL_HOST_USER=host@example.com
EMAIL_HOST_PASSWORD=your-password
SERVER_EMAIL="Server Admin <host@example.com>"
ADMIN_EMAIL="Admin <admin@example.com>"
MANAGER_EMAIL="Manager <manager@example.com>"

DRF_MFA_ENCRYPTION_KEY=your_mfa_encryption_key

# Any other key-value pairs

Create Static & Media Directories

Create these directories in your project root to handle file uploads and static assets:

shell
mkdir static staticfiles media

Create a new app

Create a new app for custom User model:

shell
uv run manage.py startapp users

Custom Models

Base Models

Add these abstract base models to a models.py file in your project root to provide UUIDs and automatic timestamps:

python
import uuid6
from django.db.models import DateTimeField, Model, UUIDField


class UUIDModel(Model):
    """Base model to add a UUID primary key."""

    id = UUIDField(
        primary_key=True,
        default=uuid6.uuid7,
        editable=False,
        help_text="System-generated unique identifier (UUID).",
    )

    class Meta:
        abstract = True


class TimeTrackedModel(Model):
    """
    Base Model to track created_dt and updated_dt fields automatically.
    """

    created_at = DateTimeField(
        auto_now_add=True, help_text="Date and time when this record was created."
    )
    updated_at = DateTimeField(
        auto_now=True, help_text="Date and time when this record was last updated."
    )

    class Meta:
        abstract = True

Custom User Model

Update users/models.py with the following code to support email-based authentication and profile tracking:

python
from django.contrib.auth.models import AbstractUser, UserManager as DjangoUserManager
from django.core.validators import RegexValidator
from django.db.models import (
    BooleanField,
    CharField,
    EmailField,
    ImageField,
)

from my_project.models import TimeTrackedModel, UUIDModel


class UserManager(DjangoUserManager):
    """
    Custom manager that treats email as a unique field.
    This subclasses the built-in UserManager for full compatibility with AbstractUser.
    """

    def create_user(
        self,
        username: str,
        email: str,
        password: str | None = None,
        **extra_fields,
    ):
        if not email:
            raise ValueError("The email field must be set")

        # Lowercase the email and set is_active to False to require email verification
        email = self.normalize_email(email).lower()
        extra_fields.setdefault("is_active", False)

        user = self.model(
            username=username,
            email=email,
            **extra_fields,
        )
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(
        self,
        username: str,
        email: str,
        password: str | None = None,
        **extra_fields,
    ):
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", True)
        extra_fields.setdefault("is_active", True)
        extra_fields.setdefault("is_email_verified", True)

        return self.create_user(username, email, password, **extra_fields)


class User(UUIDModel, TimeTrackedModel, AbstractUser):
    """
    Custom User model that extends AbstractUser to include unique email addresses
    and additional profile attributes.
    """

    first_name = CharField(
        max_length=150,
        verbose_name="first name",
        help_text="(Required) User's first name.",
    )
    email = EmailField(
        max_length=254,
        unique=True,
        verbose_name="email address",
        help_text="A unique email address required for logging in.",
    )
    phone_number = CharField(
        max_length=10,
        validators=[
            RegexValidator(
                regex=r"^[1-9]\d{9}$",
                message="Phone number must be exactly 10 digits and cannot start with zero.",
            )
        ],
        null=True,
        blank=True,
        verbose_name="phone number",
        help_text="(Optional) 10-digit phone number.",
    )
    is_email_verified = BooleanField(
        default=False,
        help_text="Indicates whether the user has verified their email address.",
    )
    is_phone_verified = BooleanField(
        default=False,
        help_text="Indicates whether the user has verified their phone number.",
    )
    profile_picture = ImageField(
        upload_to="users/avatars/",
        null=True,
        blank=True,
        help_text="Optional profile picture for the user.",
    )

    objects = UserManager()

    REQUIRED_FIELDS = ["email", "first_name"]

    def __str__(self) -> str:
        return f"{self.username} ({self.email})"

Update users/admin.py

Update users/admin.py to display your custom fields and profile pictures:

python
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils.html import format_html

from users.models import User

class CustomUserAdmin(UserAdmin):
    ordering = ("email",)
    
    # Updated list_display to include a thumbnail and the username
    list_display = (
        "get_profile_picture",
        "username",
        "email",
        "first_name",
        "is_staff",
        "is_active",
    )
    
    search_fields = ("username", "email", "first_name", "last_name")

    # Added get_profile_picture to readonly_fields for the edit page
    readonly_fields = ["id", "get_profile_picture", "created_at", "updated_at"]

    # Included username in the fieldsets to support your dual-login strategy
    fieldsets = (
        (None, {"fields": ("username", "email", "password")}),
        (
            "Personal info",
            {
                "fields": (
                    "id",
                    "first_name",
                    "last_name",
                    "phone_number",
                    "profile_picture",
                    "get_profile_picture",
                )
            },
        ),
        (
            "Permissions",
            {
                "fields": (
                    "is_active",
                    "is_staff",
                    "is_superuser",
                    "groups",
                    "user_permissions",
                )
            },
        ),
        ("Verification", {"fields": ("is_email_verified", "is_phone_verified")}),
        (
            "Important dates",
            {"fields": ("last_login", "date_joined", "created_at", "updated_at")},
        ),
    )

    # Added username to the creation form fields
    add_fieldsets = (
        (
            None,
            {
                "classes": ("wide",),
                "fields": (
                    "username",
                    "email",
                    "first_name",
                    "password1",
                    "password2",
                    "phone_number",
                    "profile_picture",
                ),
            },
        ),
    )

    @admin.display(description="Profile Picture")
    def get_profile_picture(self, obj):
        if obj.profile_picture:
            return format_html(
                '<img src="{}" style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;" />',
                obj.profile_picture.url,
            )
        return "No Image"

admin.site.register(User, CustomUserAdmin)

New Authentication Backend

Create my_project/backends.py to allow users to log in with either their username (case-sensitive) or email (case-insensitive):

python
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth import get_user_model
from django.db.models import Q

User = get_user_model()

class EmailOrUsernameBackend(ModelBackend):
    """
    Custom authentication backend that allows authenticating via 
    either username or email address.
    """
    def authenticate(self, request, username=None, password=None, **kwargs):
        try:
            # Look for a user where the input matches either username or email
            # iexact makes the check case-insensitive
            user = User.objects.get(Q(username=username) | Q(email__iexact=username))
        except User.DoesNotExist:
            return None

        # Check the password and ensure the user is allowed to authenticate
        if user.check_password(password) and self.user_can_authenticate(user):
            return user
        return None

Update settings.py

Update your settings.py to integrate the environment variables, logging, and security settings.

python
# settings.py (only changes to be made)
import os
from datetime import timedelta

from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv(os.path.join(BASE_DIR, ".env"))

# Identify test environment
is_test = os.getenv("DJ_ENV") == "TEST"

# Super Admin ID
# ADMIN_ID = os.getenv("ADMIN_ID")

# Logging for debugging purposes
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "verbose": {
            "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
            "style": "{",
        },
        "simple": {
            "format": "{levelname} {asctime} {message}",
            "style": "{",
        },
    },
    "handlers": {
        "console": {
            "level": "DEBUG",
            "class": "logging.StreamHandler",
            "formatter": "simple",
        },
        "file": {
            "level": "DEBUG",
            "class": "logging.handlers.RotatingFileHandler",
            "filename": os.path.join(BASE_DIR, "debug.log") if is_test else "/logs/debug.log",
            "formatter": "verbose",
            "maxBytes": 1024 * 1024 * 50,
            "backupCount": 5,
        },
        "mail_admins": {
            "level": "ERROR",
            "class": "django.utils.log.AdminEmailHandler",
            "formatter": "simple",
        },
    },
    "loggers": {
        "my_logger": {
            "handlers": ["console", "file"] if is_test else ["file", "mail_admins"],
            "propagate": False,
            "level": "DEBUG",
        },
    },
}

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv("SECRET_KEY")

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = is_test

ALLOWED_HOSTS = [
    "prod-public-ip",
    "example.com",
    "www.example.com",
    "api.example.com",
    "www.api.example.com",
]
if is_test:
    ALLOWED_HOSTS.extend(["localhost", "127.0.0.1"])

INSTALLED_APPS = [
    # ...
    # 3rd party
    "corsheaders",
    "rest_framework",
    "rest_framework_simplejwt",
    "rest_framework_simplejwt.token_blacklist",
    "drf_spectacular",
    "storages",
    # project apps
    "users",
    # ...
]

MIDDLEWARE = [
    ...,
    "corsheaders.middleware.CorsMiddleware", # Add CorsMiddleware before CommonMiddleware
    "django.middleware.common.CommonMiddleware",
    ...,
]

ROOT_URLCONF = "my_project.urls"

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

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.getenv("POSTGRES_DATABASE"),
        "HOST": os.getenv("POSTGRES_HOST"),
        "PORT": os.getenv("POSTGRES_PORT"),
        "USER": os.getenv("POSTGRES_USER"),
        "PASSWORD": os.getenv("POSTGRES_PASSWORD"),
        "CONN_MAX_AGE": 600,
        "CONN_HEALTH_CHECKS": True,
    }
}

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.db.DatabaseCache",
        "LOCATION": "pirate_django_cache",
    }
}

PASSWORD_RESET_TIMEOUT = 60 * 60 * 24

LANGUAGE_CODE = "en-us"
TIME_ZONE = "Asia/Kolkata"
USE_I18N = True
USE_TZ = True

STATIC_URL = "static/"
STATICFILES_DIRS = [
  BASE_DIR / "static" # project-level assets
]
STATIC_ROOT = BASE_DIR / "staticfiles" # the output directory for collectstatic

# AWS S3 and CloudFront config for django-storages go here (See this link: production/django/aws-s3-for-media.html)

if DEBUG:
    # LOCAL SETUP: serve media from local filesystem
    STORAGES = {
        "default": {
            "BACKEND": "django.core.files.storage.FileSystemStorage",
        },
        "staticfiles": {
            "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
        },
    }
    # Ensure you have these set so local files know where to save
    MEDIA_URL = "/media/"
    MEDIA_ROOT = BASE_DIR / "media"

else:
    # PROD SETUP: store media in AWS S3, serve via CloudFront signed URLs
    STORAGES = {
        "default": {
            "BACKEND": "storages.backends.s3boto3.S3Boto3Storage",
        },
        "staticfiles": {
            "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
        },
    }

if not is_test:
    # HTTPS handling
    SECURE_SSL_REDIRECT = True
    SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
    USE_X_FORWARDED_HOST = True

    # HSTS
    SECURE_HSTS_SECONDS = 31536000
    SECURE_HSTS_INCLUDE_SUBDOMAINS = True
    SECURE_HSTS_PRELOAD = True

    # Others
    CSRF_COOKIE_SECURE = True
    SESSION_COOKIE_SECURE = True

# Email Configuration (User Secure Server config)
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "smtpout.secureserver.net"
EMAIL_PORT = 465
EMAIL_USE_SSL = True
EMAIL_USE_TLS = False
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD")
SERVER_EMAIL = os.getenv("SERVER_EMAIL")
DEFAULT_FROM_EMAIL = SERVER_EMAIL

ADMINS = [os.getenv("ADMIN_EMAIL"), os.getenv("MANAGER_EMAIL")]
MANAGERS = [os.getenv("ADMIN_EMAIL"), os.getenv("MANAGER_EMAIL")]

# Custom User Model
AUTH_USER_MODEL = "users.User"

AUTHENTICATION_BACKENDS = [
    'my_project.backends.EmailOrUsernameBackend',
    # 'django.contrib.auth.backends.ModelBackend', # Default
]

# CORS (to allow all subdomains of example.com)
if is_test:
    CORS_ALLOWED_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"]
else:
    CORS_ALLOWED_ORIGINS = ["https://example.com", "https://www.example.com"]
    CORS_ALLOWED_ORIGIN_REGEXES = [
        r"^https://www\.[a-zA-Z0-9-]+\.example\.com$",
        r"^https://[a-zA-Z0-9-]+\.example\.com$",
    ]
CORS_ALLOW_CREDENTIALS = True  # required for cookie-based refresh

# CSRF
CSRF_COOKIE_DOMAIN = None if is_test else ".example.com"
CSRF_COOKIE_HTTPONLY = False  # JS must be able to read it to send it as a header
CSRF_COOKIE_SECURE = not is_test
CSRF_COOKIE_SAMESITE = "Strict"
if is_test:
    CSRF_TRUSTED_ORIGINS = ["http://localhost:3000", "http://127.0.0.1:3000"]
else:
    CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://*.example.com"]

# Session Cookie (Admin Only)
SESSION_COOKIE_DOMAIN = None if is_test else "api.example.com"
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = not is_test
SESSION_COOKIE_SAMESITE = "Strict"

# Other Security Settings
# Force HTTPS and tell browsers to remember it
if not is_test:
    SECURE_SSL_REDIRECT = True  # redirect all HTTP → HTTPS
    SECURE_HSTS_SECONDS = 31536000  # 1 year
    SECURE_HSTS_INCLUDE_SUBDOMAINS = True
    SECURE_HSTS_PRELOAD = True
# Prevent clickjacking
X_FRAME_OPTIONS = "DENY"
# Prevent MIME sniffing
SECURE_CONTENT_TYPE_NOSNIFF = True
# Control referrer header leakage
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"

# Django Rest Framework
REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication"
    ],
    "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"],
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
    "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",),
    "DEFAULT_THROTTLE_CLASSES": (
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ),
    "DEFAULT_THROTTLE_RATES": {"anon": "500/hour", "user": "100/minute"},
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 100,
}

if is_test:
    REST_FRAMEWORK["DEFAULT_AUTHENTICATION_CLASSES"].append(
        # To utilize auth done via admin UI
        "rest_framework.authentication.SessionAuthentication"
    )
    REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"].append(
        # To enable DRF UI in test environment
        "rest_framework.renderers.BrowsableAPIRenderer"
    )

# Django Rest Framework (Simple JWT)
SIMPLE_JWT = {
    # Keep access tokens short-lived in production.
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=30),
    # Issue a new refresh token on every /token/refresh/ call.
    "ROTATE_REFRESH_TOKENS": True,
    # Blacklist the old refresh token after rotation.
    "BLACKLIST_AFTER_ROTATION": True,
    "UPDATE_LAST_LOGIN": True,
    "AUTH_HEADER_TYPES": ("Bearer",),
}

# Refresh Token Cookie
AUTH_COOKIE_NAME = "pirate_token"
AUTH_COOKIE_DOMAIN = None if is_test else ".example.com"
AUTH_COOKIE_SECURE = not is_test
AUTH_COOKIE_SAMESITE = "Strict"
TOKEN_REFRESH_URL = "/auth/token/refresh/"

# MFA Settings
DRF_MFA_SETTINGS = {
    "TOKEN_VALID_WINDOW": 1,
    "ENCRYPTION_KEY": os.getenv("DRF_MFA_ENCRYPTION_KEY"),
    "ISSUER_NAME": "Example.Com",
}

# API Docs
SPECTACULAR_SETTINGS = {
    "TITLE": "Example.Com API",
    "DESCRIPTION": "API Documentation of Example.Com",
    "VERSION": "1.0.0",
    "SERVE_INCLUDE_SCHEMA": False,
    "SERVE_PERMISSIONS": [
        "rest_framework.permissions.IsAdminUser",
        # "my_project.common.permissions.IsSuperAdmin",
    ],
    "SWAGGER_UI_SETTINGS": {
        "persistAuthorization": True,
    },
}

Update urls.py

Update project-level urls.py to serve static and media files from STATIC_ROOT and MEDIA_ROOT respectively and to allow Admin UI access locally:

python
# urls.py
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from drf_spectacular.views import (
    SpectacularAPIView,
    SpectacularSwaggerView,
)

urlpatterns = [
    # API Docs
    path("v1/schema/", SpectacularAPIView.as_view(), name="schema"),
    path(
        "v1/docs/",
        SpectacularSwaggerView.as_view(url_name="schema"),
        name="swagger-ui",
    ),
    # your URL patterns
]

if settings.DEBUG:
    urlpatterns += [path("admin/", admin.site.urls)]
    urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Update wsgi.py

Update wsgi.py to avoid server errors in production environment later:

python
import os
import sys
from pathlib import Path

from django.core.wsgi import get_wsgi_application

# To load all project-level env variables
SETTINGS_DIR = Path(__file__).resolve().parent

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

# Add following lines to the file, else you will run into "ModuleNotFoundError: No module named ... error
# (Web page will show Internal Server Error)
sys.path.append(str(SETTINGS_DIR))

application = get_wsgi_application()

Final Steps and Verification

Run the following commands to apply your database schema and collect static files:

shell
# Apply migrations
uv run manage.py makemigrations
uv run manage.py migrate

# Initialize system tables
uv run manage.py createcachetable
uv run manage.py collectstatic

# Create admin access
uv run manage.py createsuperuser

Verify your setup by running the built-in check and starting the server:

shell
uv run manage.py check
uv run manage.py runserver localhost:8000

You can now access the Admin Dashboard at http://localhost:8000/admin to manage your users.

Connect to GitHub

Initialize git and link the project to a GitHub repo:

shell
git init
git add .
git commit -m "Initial commit"
git branch -M main
git remote add origin <remote-URL> # Replace with your GitHub repository URL
git push -u origin main