One of the easiest mistakes to make with a new Django project is to treat production as something you will think about later.
You create the project, add an app, put everything into settings.py, use the default user model and start building features.
That is completely reasonable for a prototype.
The problem is that some of those early decisions become expensive to change once the application has real users, data, integrations and a team working on it.
I prefer to make a small number of structural decisions at the beginning.
Not because I know exactly what the application will look like in two years. I don't.
I do it because there are certain concerns that nearly every production application will need eventually, and I would rather give them a clear home from the start.
This is the structure I use.
It is opinionated. It is not the only way to structure a Django project, and I would not expect every team to make the same choices.
It is simply the structure I have arrived at after building and maintaining Django applications in production.
What I mean by production-ready
When I describe a project as being ready for production from day one, I am not suggesting that a newly created application is automatically ready to handle millions of users.
Production-ready means something much simpler to me.
The project already has sensible places for:
- Environment-specific settings
- Secrets and environment variables
- Authentication
- Shared application foundations
- Business logic
- APIs
- Background tasks
- Static and media storage
- Caching
- Logging and error tracking
- Testing
- Deployment configuration
Most of these do not need to be fully configured on day one.
They just need a clear place to live when they are needed.
That distinction matters.
I do not want to add architecture for hypothetical requirements. I want to avoid restructuring the entire project when completely predictable requirements arrive.
The project structure
A simplified version of the structure I use looks like this:
app/
├── manage.py
├── api/
│ ├── authentication.py
│ ├── dtos.py
│ ├── serializers.py
│ ├── urls.py
│ └── views.py
├── app/
│ ├── backends/
│ │ ├── email_backends/
│ │ └── storage_backends/
│ ├── settings/
│ │ ├── base.py
│ │ ├── dev.py
│ │ ├── prod.py
│ │ └── test.py
│ ├── celery.py
│ ├── urls.py
│ ├── asgi.py
│ └── wsgi.py
├── authentication/
│ ├── forms.py
│ ├── managers.py
│ ├── models.py
│ ├── services.py
│ ├── urls.py
│ └── views.py
├── core/
│ ├── context_processors.py
│ ├── decorators.py
│ ├── dtos.py
│ ├── forms.py
│ ├── models.py
│ ├── services.py
│ ├── types.py
│ └── views.py
├── projects/
│ ├── dtos.py
│ ├── forms.py
│ ├── models.py
│ ├── services.py
│ ├── tasks.py
│ ├── urls.py
│ └── views.py
├── assets/
├── static/
└── templates/
├── base.html
├── components/
├── authentication/
├── core/
└── projects/
The exact names are not important.
The boundaries are.
I want to be able to open a project six months later and have a good idea where something belongs before I start searching for it.
Keep the Django project package boring
The inner project package, called app in my starter projects, is configuration.
I try not to put application behaviour in there.
It contains things such as:
- Settings
- Root URL configuration
- WSGI and ASGI entry points
- Celery configuration
- Infrastructure-specific backends
That is deliberate.
I do not want domain logic mixed into the package responsible for bootstrapping Django.
If I am implementing a customer workflow, invoice calculation or subscription rule, it belongs in the relevant application, not in the project package.
The project package should mostly answer: how does this Django application run?
The domain applications should answer: what does this Django application do?
That separation keeps the top level of the project much easier to reason about.
Split settings from the beginning
One of the first things I change is Django's single settings.py file.
I use:
settings/ ├── base.py ├── dev.py ├── prod.py └── test.py
base.py contains configuration shared by every environment.
That includes installed applications, middleware, templates, authentication configuration, REST framework defaults, Celery configuration and logging.
For example:
DEBUG = False INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "rest_framework", "core", "authentication", "api", "projects", ]
Development then changes only what development needs:
from .base import * DEBUG = True ALLOWED_HOSTS = ["*"] EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" CACHES = { "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", }, }
Production can make different assumptions:
from .base import * DEBUG = False SECRET_KEY = env("SECRET_KEY") ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[]) DATABASES = { "default": env.db("DATABASE_URL"), } SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True SECURE_SSL_REDIRECT = True
Tests get their own configuration too.
For example, I use a fast password hasher, local-memory email and caching, eager background tasks and disabled API throttling during tests.
The advantage is not just tidiness.
It makes the assumptions of each environment explicit.
I do not want production behaviour hidden behind dozens of if DEBUG: checks scattered throughout one large settings file.
I would rather be able to read prod.py and understand what changes when the application is deployed.
Environment variables are part of the structure
Configuration that changes between environments should not be hard-coded into the repository.
I use environment variables for things such as:
SECRET_KEY DATABASE_URL REDIS_URL SENTRY_DSN MAILGUN_API_KEY AZURE_ACCOUNT_NAME AWS_STORAGE_BUCKET_NAME
For local development, I load these through a .env file.
The .env file is ignored by Git, while .env.example documents the available configuration without containing real credentials.
This seems like a small detail, but it becomes increasingly important as more people and environments are added to a project.
A developer should be able to look at .env.example and understand what can be configured without having to search through the codebase.
Create the user model before the first migration
This is one decision I make on almost every new Django project.
I create a custom user model before running the first migration.
In most business applications I use email as the login identifier rather than a separate username.
A simplified version looks like this:
class User(AbstractUser): username = None email = models.EmailField( "email address", unique=True, ) USERNAME_FIELD = "email" REQUIRED_FIELDS = []
Then in the base settings:
AUTH_USER_MODEL = "authentication.User"
Even if I do not currently need additional fields on the user, I still prefer owning the model from the beginning.
Authentication is foundational.
Changing the user model after an application has accumulated migrations, foreign keys and production data is not a job I want to create for myself if I can avoid it.
The same principle applies to authentication flows.
Login, signup and password reset live inside a dedicated authentication app rather than being scattered around the project.
Organise applications around responsibility
I prefer Django apps that have a clear reason to exist.
For example:
authentication/ projects/ billing/ customers/ reporting/
I do not create applications purely because a file is becoming large.
An app should represent a meaningful area of the system.
Within that application, I then keep related pieces together:
projects/ ├── admin.py ├── dtos.py ├── forms.py ├── models.py ├── services.py ├── tasks.py ├── urls.py └── views.py
That means when I am working on projects, most of the code I need is inside the projects package.
This also avoids another structure I tend not to use:
models/ services/ views/ forms/
with every domain in the system split across global technical folders.
For me, grouping by business responsibility scales better because a feature stays relatively self-contained.
Have a core app, but keep it disciplined
I normally have a core application.
This is where shared foundations live.
In my starter projects that includes:
- A base model
- Base service behaviour
- Shared DTOs
- Common type aliases
- Generic decorators
- Shared form behaviour
- Context processors
- Basic application-level views
The important word there is shared.
core should not become a dumping ground for anything that does not immediately have a home.
If I find myself adding core/utils.py and filling it with unrelated helper functions, that is usually a sign that the boundaries need another look.
A good rule I use is that something belongs in core when several parts of the application genuinely depend on it and it is not specific to one business domain.
Establish model conventions early
Most applications end up repeating the same model concerns.
Created timestamps.
Updated timestamps.
Audit information.
External identifiers.
Sometimes soft deletion.
Rather than implementing those differently across every app, I use an abstract BaseModel.
A simplified version looks like this:
class BaseModel(models.Model): guid = models.UUIDField( default=uuid.uuid4, unique=True, editable=False, db_index=True, ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) created_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+", ) updated_by = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL, related_name="+", ) class Meta: abstract = True
I use the normal integer primary key internally and a GUID when records need to be exposed externally.
That gives me efficient and conventional database relationships without exposing sequential internal IDs in URLs or APIs.
The point here is not that every Django project needs exactly these fields.
It is that I decide the model conventions early so the application does not end up with five slightly different versions of the same idea.
I will cover my base model and identifier approach in more detail in a separate post.
Give business logic a clear home
This follows directly from my previous article on keeping Django views thin.
I do not want views to become the place where business behaviour accumulates.
In my projects, each domain can expose its operations through a service:
class ProjectService(BaseService[Project]): model = Project def create_project( self, payload: ProjectPayload, ) -> Project: return self.create(**payload.as_dict())
The web view calls the service.
The API calls the same service.
A background task can call the same service.
This gives the business operation one implementation rather than one implementation per interface.
The exact service-layer approach is opinionated, and it is not something I would force into a small CRUD project.
But if I know I am building a business application that will have an HTML interface, an API, background jobs and non-trivial rules, I prefer creating that boundary early.
Treat the API as another interface
I normally keep the REST API in its own api application.
That app owns API-specific concerns:
api/ ├── authentication.py ├── dtos.py ├── serializers.py ├── urls.py └── views.py
What it does not own is a second copy of the business logic.
For example, an API view can validate incoming JSON with a serializer and then call the same domain service used by the web application:
class ProjectViewSet(ModelViewSet): lookup_field = "guid" @property def service(self): return ProjectService(user=self.request.user) def create(self, request, *args, **kwargs): serializer = ProjectWriteSerializer( data=request.data ) serializer.is_valid(raise_exception=True) project = self.service.create_project( serializer.to_payload() ) return Response( ProjectSerializer(project).data, status=status.HTTP_201_CREATED, )
This keeps HTTP and serialisation concerns inside the API layer while the application behaviour stays reusable.
Whether the request came from HTMX, a normal Django form or an API client should not change the rules of the application.
Structure templates deliberately
Templates can become messy surprisingly quickly.
I use a project-level templates directory with clear namespaces:
templates/ ├── base.html ├── components/ ├── authentication/ ├── core/ └── projects/
base.html owns the main document structure.
Application-specific templates stay under their own directory.
Reusable interface pieces go into components.
For HTMX-heavy applications, I also normally have a partials directory inside the relevant application templates:
projects/
├── list.html
└── partials/
├── project_form.html
├── project_table.html
└── stats.html
This might seem like a frontend concern rather than project architecture, but templates are part of the application structure.
If reusable fragments, full pages and domain-specific templates are all mixed together, the frontend becomes harder to navigate just as quickly as Python code does.
Put production infrastructure behind clear adapters
Another thing I try to avoid is coupling application code directly to whichever infrastructure provider I happen to be using today.
For example, my project package has dedicated backend modules for email and file storage:
app/
└── backends/
├── email_backends/
│ └── mailgun.py
└── storage_backends/
└── azure.py
Development can use Django's console email backend and local file storage.
Production can use Mailgun, Azure Blob Storage or S3.
The rest of the application should not need to care.
The same applies to caching.
Locally, an in-memory cache is fine.
In production, setting REDIS_URL can switch the application to Redis.
I want those differences to happen in configuration, not by adding infrastructure checks throughout the business code.
Make background work possible before you need it
Not every application needs Celery on its first day.
Most do not.
I still like the project to have a clear path for background work.
The starter structure includes a Celery application and allows the broker to be configured using environment variables.
Locally, tasks can run eagerly.
In production, a Redis-backed worker can be enabled when the project actually needs one.
That means I am not paying the operational cost of running a worker before it is useful, but I also do not have to redesign how the application is structured when emails, imports, report generation or other long-running processes need to leave the request cycle.
That is what I mean by preparing for production rather than overengineering for production.
Logging and error tracking are not afterthoughts
Production applications fail.
What matters is whether I can see why.
I configure normal Python and Django logging in the shared settings from the beginning.
Production can then enable Sentry through an environment variable:
SENTRY_DSN = env("SENTRY_DSN", default="") if SENTRY_DSN: sentry_sdk.init( dsn=SENTRY_DSN, environment="production", send_default_pii=False, )
Again, this does not mean Sentry must be configured while I am prototyping.
It means the application already has an obvious place for production error reporting when it is deployed.
Observability is much easier to introduce before the first production incident than during it.
Testing is part of the structure
I do not treat testing as something to bolt onto a project once it becomes complicated.
The project is configured for pytest from the beginning.
The test settings make the suite fast and isolated, and each application has an obvious place for its tests.
I also configure Ruff at project level for linting, imports and formatting rules.
The important part is consistency.
A new developer should be able to clone the repository and know how to:
uv sync uv run pytest uv run ruff check .
without first learning a collection of undocumented local commands.
The earlier that workflow exists, the less friction there is around maintaining it.
Make deployment reproducible
I also like deployment configuration to live with the project.
My starter projects include a render.yaml and a small build script.
The build process is explicit:
uv sync --frozen --no-dev cd app uv run python manage.py collectstatic --noinput uv run python manage.py migrate
The web process then starts Gunicorn using the production settings module.
The hosting provider is less important than the principle.
I want deployment to be repeatable and visible in version control.
I do not want the only copy of the production setup to exist as a series of settings somebody clicked in a hosting dashboard six months ago.
What I deliberately do not do
Starting with production in mind does not mean trying to predict every future requirement.
There are several things I deliberately avoid.
I do not create an app for every model
Applications should represent meaningful responsibilities, not individual database tables.
I do not build abstractions before I have a use for them
A base service is useful to me because I repeatedly need the same persistence and audit behaviour.
That does not mean every repeated three-line function needs a framework around it.
I do not run every production service locally
SQLite, console email, local storage, an in-memory cache and eager tasks make local development simple.
The application can use PostgreSQL, Redis, external email, cloud storage and workers in production without forcing every developer to run that entire stack just to change a template.
I do not put every shared function into core
Shared code still needs a reason to exist.
I do not let the project structure replace judgement
No directory layout will stop an application becoming difficult to maintain.
The structure only gives good decisions somewhere consistent to live.
The principle behind the structure
The individual directories are not really the important part.
My main goal is to establish boundaries early.
I want:
- Configuration separated from business behaviour
- Environments separated from each other
- Domain code grouped by responsibility
- Views focused on HTTP
- Business operations reusable between interfaces
- Infrastructure configurable rather than hard-coded
- Testing and deployment treated as normal development concerns
That gives the application room to grow without trying to design the entire future system on day one.
When a new requirement arrives, the first question is usually obvious.
Is this configuration?
Is it authentication?
Does it belong to a particular domain?
Is it business logic?
Is it an interface concern?
Is it infrastructure?
A good project structure does not eliminate architectural decisions.
It makes those decisions easier to see.
Do all Django projects need this structure?
No.
If I am building a small internal tool, a prototype or a simple content site, I will happily use less structure.
There is no value in creating six layers simply because a diagram says they should exist.
But when I know I am starting a Django application that is intended to become a real product or long-lived business system, I would rather establish these foundations before the feature work accelerates.
Changing a settings layout is easy.
Changing authentication, model conventions, application boundaries and duplicated business logic after a system has grown is not.
That is why I make these decisions early.
Not because the project is already complex.
Because I know which kinds of complexity are likely to arrive.
Final thoughts
The default Django project gives you everything you need to start building.
For production applications, I add structure around it before I add much domain code.
I split settings by environment.
I create the user model early.
I keep shared foundations in a disciplined core app.
I organise domain apps around business responsibilities.
I keep business logic outside the HTTP layer.
I make production infrastructure configurable.
And I make testing and deployment part of the project rather than jobs for later.
None of those decisions are particularly exciting.
That is partly the point.
Good project structure should disappear into the background and let the team spend its time building the application.
The structure shown in this article is the same foundation I use in my own Django starter projects. It has evolved from the things I found myself setting up repeatedly before I could get to the first real feature.
The aim is simple:
Start at the first feature, not the first config file.