Skip to main content

Your First Django-CFG Project

What You'll Build

A complete multi-tenant SaaS application with type-safe YAML configuration, workspace management, and production-ready setup in under 15 minutes.

Project Creation Flow

Goal

Create a Django-based SaaS platform with type-safe YAML configuration using Django-CFG.

Prerequisites

Requirements

Before starting, ensure you have:

  • ✅ Python 3.12+ installed (Installation Guide)
  • ✅ Basic Django knowledge
  • ✅ Code editor with Python support (VS Code, PyCharm, etc.)
  • ✅ Command line/terminal access

Step-by-Step Tutorial

1. Create Project Structure

# Create project directory
mkdir saas-platform
cd saas-platform

# Create virtual environment
python3.12 -m venv .venv
source .venv/bin/activate

# Install Django and Django-CFG
pip install django django-cfg

# Create Django project
django-admin startproject core .
Verify Installation

Check that Django-CFG is installed correctly:

python -c "import django_cfg; print(django_cfg.__version__)"

2. Create Environment Configuration Module

Create directory structure:

saas-platform/
├── core/
│ ├── __init__.py
│ ├── environment/ # New directory
│ │ ├── __init__.py # New file
│ │ ├── loader.py # New file
│ │ └── config.dev.yaml # New file
│ ├── config.py # New file
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
└── manage.py

core/environment/init.py:

"""Environment configuration loader."""
from .loader import env

__all__ = ["env"]

core/environment/loader.py:

"""
Environment configuration with [Pydantic models](/fundamentals/core/type-safety) and YAML loading.

Based on django-cfg libs/django_cfg_opensource/sample/django/api/environment/loader.py
"""
import os
from pathlib import Path
from pydantic import BaseModel, Field, computed_field
from pydantic_yaml import parse_yaml_file_as


# Environment detection
IS_DEV = os.environ.get("IS_DEV", "").lower() in ("true", "1", "yes")
IS_PROD = os.environ.get("IS_PROD", "").lower() in ("true", "1", "yes")
IS_TEST = os.environ.get("IS_TEST", "").lower() in ("true", "1", "yes")

# Default to development
if not any([IS_DEV, IS_PROD, IS_TEST]):
IS_DEV = True


class DatabaseConfig(BaseSettings):
"""Database configuration."""
url: str = Field(default="sqlite:///db.sqlite3")

model_config = SettingsConfigDict(
env_prefix="DATABASE__",
env_nested_delimiter="__",
)


class AppConfig(BaseSettings):
"""Application configuration."""
name: str = Field(default="SaaS Platform")
site_url: str = Field(default="http://localhost:3000")
api_url: str = Field(default="http://localhost:8000")

model_config = SettingsConfigDict(
env_prefix="APP__",
env_nested_delimiter="__",
)


class EnvironmentMode(BaseSettings):
"""Environment mode detection."""
is_test: bool = Field(default=False)
is_dev: bool = Field(default=False)
is_prod: bool = Field(default=False)

@model_validator(mode="after")
def set_default_env(self):
"""Set development as default if no env specified."""
if not any([self.is_test, self.is_dev, self.is_prod]):
self.is_dev = True
return self

@computed_field
@property
def env_mode(self) -> str:
"""Get environment mode string."""
if self.is_test:
return "test"
elif self.is_dev:
return "development"
elif self.is_prod:
return "production"
return "development"


class EnvironmentConfig(BaseSettings):
"""Complete environment configuration."""

# Core settings
secret_key: str = Field(
default="dev-secret-key-at-least-fifty-characters-long-for-django-security"
)
debug: bool = Field(default=True)

# Configuration sections
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
app: AppConfig = Field(default_factory=AppConfig)
env: EnvironmentMode = Field(default_factory=EnvironmentMode)

# Security
security_domains: list[str] | None = None

model_config = SettingsConfigDict(
env_file=str(Path(__file__).parent / ".env"),
env_file_encoding="utf-8",
env_nested_delimiter="__",
case_sensitive=False,
extra="ignore",
)


# Global environment configuration instance
# Auto-loads from ENV > .env > defaults
env = EnvironmentConfig()

core/environment/.env:

# Development configuration
SECRET_KEY="dev-secret-key-at-least-fifty-characters-long-for-django-security"
DEBUG=true

# Application
APP__NAME="SaaS Platform"
APP__SITE_URL="http://localhost:3000"
APP__API_URL="http://localhost:8000"

# Database
DATABASE__URL="sqlite:///db.sqlite3"

# Security domains - optional in development
# Django-CFG auto-configures for dev convenience:
# - CORS fully open (CORS_ALLOW_ALL_ORIGINS=True)
# - Docker IPs work automatically
# - localhost any port
# SECURITY_DOMAINS="localhost,127.0.0.1"

3. Create Configuration Class

core/config.py:

"""
SaaS Platform configuration using Django-CFG.
"""
from django_cfg import DjangoConfig, DatabaseConfig
from typing import Dict
from .environment import env # Type-safe ENV loader


class SaaSConfig(DjangoConfig):
"""SaaS platform configuration from environment variables."""

# From environment
secret_key: str = env.secret_key
debug: bool = env.debug
env_mode: str = env.env.env_mode

# Project info
project_name: str = env.app.name
site_url: str = env.app.site_url
api_url: str = env.app.api_url

# Security (optional in development, required in production)
security_domains: list[str] | None = env.security_domains

# Database from YAML URL (see /fundamentals/database for multi-database setup)
databases: Dict[str, DatabaseConfig] = {
"default": DatabaseConfig.from_url(url=env.database.url)
}

# Project apps
project_apps: list[str] = [
"core.apps.workspaces", # Will create this app
]


# Create config instance
config = SaaSConfig()

4. Update settings.py

Replace core/settings.py content:

"""
Django settings for SaaS platform.

Uses Django-CFG for type-safe configuration.
"""
from pathlib import Path
from .config import config

# Build paths
BASE_DIR = Path(__file__).resolve().parent.parent

# Import all Django-CFG settings
globals().update(config.get_all_settings())

# Override BASE_DIR (Django-CFG doesn't manage this)
BASE_DIR = BASE_DIR

# Static files
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'

MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

# Default primary key
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

5. Create Workspace App

# Create workspaces app
python manage.py startapp workspaces core/apps/workspaces

# Create __init__.py in apps directory
mkdir -p core/apps
touch core/apps/__init__.py

core/apps/workspaces/models.py:

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


class Workspace(models.Model):
"""Multi-tenant workspace for SaaS platform."""

name = models.CharField(max_length=200)
slug = models.SlugField(unique=True)
description = models.TextField(blank=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='owned_workspaces')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_active = models.BooleanField(default=True)

# SaaS features
plan = models.CharField(max_length=50, default='free', choices=[
('free', 'Free'),
('pro', 'Professional'),
('enterprise', 'Enterprise')
])
max_members = models.IntegerField(default=5)

class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['slug']),
models.Index(fields=['owner', 'is_active']),
]

def __str__(self):
return self.name


class WorkspaceMember(models.Model):
"""Workspace membership with roles."""

workspace = models.ForeignKey(Workspace, on_delete=models.CASCADE, related_name='members')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='workspace_memberships')
role = models.CharField(max_length=20, default='member', choices=[
('owner', 'Owner'),
('admin', 'Admin'),
('member', 'Member'),
('viewer', 'Viewer'),
])
joined_at = models.DateTimeField(auto_now_add=True)

class Meta:
unique_together = [['workspace', 'user']]
ordering = ['-joined_at']

def __str__(self):
return f"{self.user.username} - {self.workspace.name} ({self.role})"

core/apps/workspaces/admin.py:

from django.contrib import admin
from .models import Workspace, WorkspaceMember


@admin.register(Workspace)
class WorkspaceAdmin(admin.ModelAdmin):
list_display = ['name', 'owner', 'plan', 'is_active', 'created_at']
list_filter = ['plan', 'is_active', 'created_at']
search_fields = ['name', 'description']
prepopulated_fields = {'slug': ('name',)}
readonly_fields = ['created_at', 'updated_at']


@admin.register(WorkspaceMember)
class WorkspaceMemberAdmin(admin.ModelAdmin):
list_display = ['workspace', 'user', 'role', 'joined_at']
list_filter = ['role', 'joined_at']
search_fields = ['workspace__name', 'user__username']
readonly_fields = ['joined_at']

6. Run Migrations and Create Superuser

# Create database tables
python manage.py migrate

# Create admin user
python manage.py createsuperuser
# Enter: username, email, password

# Run development server
python manage.py runserver

7. Access Your Application

Open browser:

Login to admin with created superuser credentials.

Production Configuration

Create core/environment/config.prod.yaml:

secret_key: ""  # Set via environment variable or secrets manager
debug: false

app:
name: "SaaS Platform"
site_url: "https://platform.example.com"
api_url: "https://api.platform.example.com"

# REQUIRED in production - Django-CFG auto-normalizes any format
security_domains:
- "platform.example.com" # ✅ No protocol
- "api.platform.example.com" # ✅ Subdomain

# SSL/TLS: Assumes reverse proxy (nginx, Cloudflare, etc.) handles HTTPS
# No ssl_redirect needed - Django-CFG defaults to reverse proxy mode

database:
url: "postgresql://user:pass@db.example.com:5432/saas_prod"

Deploy with:

IS_PROD=true python manage.py runserver

Adding Features

Enable Built-in Apps

Edit core/config.py:

class SaaSConfig(DjangoConfig):
# ... existing config ...

# Enable built-in features (see /features/built-in-apps/overview for all apps)
enable_support: bool = True # Support tickets
enable_accounts: bool = True # Extended user management
enable_newsletter: bool = True # Email campaigns
enable_payments: bool = True # Subscription billing

Add Caching

Update core/environment/loader.py (see Cache Configuration for advanced options):

class EnvironmentConfig(BaseModel):
# ... existing fields ...

# Cache
redis_url: str | None = None

Update core/environment/config.dev.yaml:

# ... existing config ...

redis_url: "redis://localhost:6379/0"

Update core/config.py:

from django_cfg import DjangoConfig, DatabaseConfig

class SaaSConfig(DjangoConfig):
# ... existing config ...

# ✨ Auto Redis cache - just set redis_url!
redis_url: str | None = env.redis_url
# Django-CFG automatically creates CacheConfig if redis_url is set

Troubleshooting

See Troubleshooting Guide for complete solutions.

Configuration Not Loading

# Check which config file is loaded
python manage.py shell
>>> from core.environment import env
>>> print(env.env.env_mode)
development

YAML Parse Error

# Validate YAML syntax
python -c "import yaml; yaml.safe_load(open('core/environment/config.dev.yaml'))"

Pydantic Validation Error

# Check error details
python manage.py check

Next Steps

TAGS: tutorial, first-project, yaml, pydantic, django-cfg, saas, multi-tenant DEPENDS_ON: [installation, configuration] USED_BY: [configuration, built-in-apps]