Before writing the first business feature, I spent time designing the infrastructure for a new enterprise level full stack application. The backend is Django, the frontend is React with TypeScript, the database is PostgreSQL, and the cloud destination is Microsoft Azure.
It would have been easy to start with a model, build a screen and connect the two with an API. That gives visible progress very quickly. I decided to do the opposite.
I wanted the local environment to establish the same important boundaries that the application will keep when it moves into a shared development environment and later into production.
Before the first feature
The application will move through three environments.
Developer laptop
- Frontend React and Vite
- Backend Django
- Database PostgreSQL 16
Azure development
- Frontend Azure Static Web Apps
- Backend Azure App Service
- Database Azure PostgreSQL
Azure production
- Frontend Azure Static Web Apps
- Backend Azure App Service
- Database Azure PostgreSQL
The local environment does not need to be an exact copy of Azure. React and Django can run directly on my laptop while Azure runs them as managed services.
What matters more is that the main boundaries stay the same. The frontend remains separate from the API. The API owns the database. PostgreSQL is used from the beginning. Configuration comes from the environment, and the frontend and backend can be deployed independently.
Start with the repository boundary
The workspace started with two folders.
project ├── frontend └── backend
The parent folder is only a workspace. It is not a Git repository.
The frontend and backend each have their own repository.
project
├── frontend
│ └── .git
│
└── backend
└── .git
I chose this structure because the two applications will eventually be deployed separately.
The React application will go to Azure Static Web Apps. The Django API will go to Azure App Service. They have different dependencies, build processes and release jobs.
Use two Git repositories
The frontend and backend are separate deployment units, so I keep them as separate source repositories as well. The parent workspace remains a normal folder.
Build the frontend with the current React stack
I started the frontend using the current Vite template for React and TypeScript.
npm create vite@latest . -- --template react-ts --no-interactive
npm install
npm run dev
The development server started at http://localhost:5173.
I then checked the actual versions installed by the current scaffold. I did not want an older planning document to silently decide which versions a new application should use.
Some of the first technical notes for the application referenced older versions of React and Vite. I decided not to downgrade a clean modern scaffold just to make it match an old document.
Documentation follows the application
The lock file describes what the application actually uses. Documentation should be updated when the implementation changes.
Keep the Django environment isolated
The backend uses Python 3.12. I created a virtual environment inside the backend repository.
python -m venv .venv
.\.venv\Scripts\activate
The virtual environment keeps the Python packages for this application separate from other Python projects on the machine.
The .venv directory is local development infrastructure. It is ignored by Git.
Use PostgreSQL from the first migration
Django starts with SQLite because it makes a new project very easy to run. For a prototype that is useful. For this application I wanted something different.
Both the Azure development environment and the production environment will use PostgreSQL. I wanted the local application to use the same database engine.
SQLite locally
Very simple startup, but the database engine changes when the application moves to Azure.
PostgreSQL locally
A little more setup, but the same database engine is used in local development, Azure development and Azure production.
The difference may not matter when an application has only a few tables and simple queries. It becomes more important as the application grows.
Constraints matter. Indexes matter. Transactions matter. Concurrency matters. JSON behaviour matters. Database specific features matter.
Use PostgreSQL from day one
If PostgreSQL will be the production database, I would rather find PostgreSQL problems while developing locally than after deployment.
PostgreSQL was the decision. Docker was the tool.
This was one of the most useful distinctions in the setup.
I did not need Docker because the application is intended for enterprise use. I needed PostgreSQL locally. Docker gave me a clean way to run it.
Everything in Docker
React, Django and PostgreSQL all run in containers.
Hybrid local setup
React and Django run directly on Windows. PostgreSQL runs in Docker.
This keeps normal frontend and backend development simple while still giving the application a repeatable database environment.
Windows host
- Runtime Node.js
- Framework React and Vite
- Port 5173
Windows host
- Runtime Python 3.12
- Framework Django
- Port 8000
Docker
- Engine PostgreSQL 16
- Storage Docker volume
- Port 5432
Define PostgreSQL with Docker Compose
The database definition lives in backend/compose.yaml.
services:
db:
image: postgres:16
container_name: app-postgres-local
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
The named volume matters because the container and the database data should not have the same lifetime.
I want the container to be disposable. I do not want the local database to disappear simply because the PostgreSQL container is recreated.
The health check also gives me something better than knowing that the container process started. It checks whether PostgreSQL is ready to accept connections.
Keep real configuration outside Git
The Compose file does not contain the real database password. It reads the values from environment variables.
The backend has two environment files with different jobs.
backend ├── .env └── .env.example
.env contains the real local values and is ignored by Git.
.env.example documents the configuration the application expects without storing real secrets.
DJANGO_SECRET_KEY=replace-with-local-secret
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
POSTGRES_DB=app_local
POSTGRES_USER=app_user
POSTGRES_PASSWORD=replace-with-local-password
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
Commit the contract, not the secret
The repository can explain which configuration values are required without containing the real credentials.
Start the database and prove that it is healthy
Before starting PostgreSQL, I asked Docker Compose to validate the configuration.
docker compose config --quiet
docker compose up -d
docker compose ps
app-postgres-local
Up
healthy
0.0.0.0:5432 → 5432/tcp
PostgreSQL 16 was running in Docker and accepting connections on localhost port 5432.
Choose Django 5.2 LTS deliberately
When I first installed the backend packages without restricting the Django version, pip selected the newest available Django release.
It would have worked with the Python version I was using. I still chose not to keep it.
For this application I selected the Django 5.2 LTS line.
Django 5.2.17
This application is expected to live for years. I prefer a stable support path over choosing a release simply because it has the highest version number.
Use the LTS release line
Newest and most suitable are not always the same thing. For the first version of this application I prefer the predictable support path of Django 5.2 LTS.
Install the backend foundation before the business code
I installed the packages needed for the application foundation before creating business apps.
pip install django djangorestframework drf-spectacular "psycopg[binary]" django-environ django-q2 ruff pytest pytest-django
Django provides the application framework. Django REST Framework provides the API layer. drf spectacular gives the project an OpenAPI foundation. psycopg connects Django to PostgreSQL.
django environ handles environment based configuration. Django Q2 provides a starting point for background work. Ruff handles Python linting and formatting. Pytest provides the testing foundation.
After installing the packages I checked the Python environment.
python -m pip check
No broken requirements found.
Create the Django project without another folder
I created the Django project directly inside the backend repository.
django-admin startproject config .
The final dot matters. It tells Django to use the current directory instead of creating another nested project folder.
backend ├── config │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py │ ├── compose.yaml ├── manage.py ├── requirements.txt ├── .env ├── .env.example └── .gitignore
There were still no business apps at this point. I wanted the framework and infrastructure working before adding application behaviour.
Replace SQLite before the first migration
Django generated its normal SQLite configuration. I changed it before running a single migration.
The settings load values through django-environ.
from pathlib import Path
import environ
BASE_DIR = Path(__file__).resolve().parent.parent
env = environ.Env(
DEBUG=(bool, False),
)
environ.Env.read_env(BASE_DIR / ".env")
SECRET_KEY = env("DJANGO_SECRET_KEY")
DEBUG = env.bool("DEBUG", default=False)
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[])
The database settings also read from the environment.
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": env("POSTGRES_DB"),
"USER": env("POSTGRES_USER"),
"PASSWORD": env("POSTGRES_PASSWORD"),
"HOST": env("POSTGRES_HOST", default="127.0.0.1"),
"PORT": env("POSTGRES_PORT", default="5432"),
}
}
Do not assume the database connection works
A configuration file can look correct while the database is still unreachable.
I asked Django to open a real connection before running migrations.
python manage.py shell -c "from django.db import connection; connection.ensure_connection(); print(connection.vendor); print(connection.settings_dict['NAME'])"
postgresql
app_local
Django had opened a real PostgreSQL connection. The project was no longer only configured correctly on paper.
Let the database history start on PostgreSQL
I ran Django's system check before creating any tables.
python manage.py check
python manage.py migrate
Django created its authentication, administration, session and content type tables directly in PostgreSQL.
There was no temporary SQLite phase and no db.sqlite3 file to replace later.
I then started the Django development server.
python manage.py runserver
Django loaded successfully at http://127.0.0.1:8000 and was using PostgreSQL 16.
Give the frontend one local API path
The frontend runs on port 5173. Django runs on port 8000.
I did not want local backend addresses repeated throughout the frontend code. Instead I configured Vite to proxy requests beginning with /api.
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
})
The React application can now use a path such as /api/health. During local development Vite can forward the request to Django.
React
- Address localhost:5173
- API path /api
Vite
- Receives /api/*
- Forwards to port 8000
Django
- Address 127.0.0.1:8000
- Database PostgreSQL
After changing the configuration I also ran a full frontend build.
npm run build
TypeScript compiled successfully and Vite produced the frontend production build.
Prepare the API contract before building the API
Django REST Framework and drf spectacular are already part of the backend foundation.
Django REST Framework will provide the API. drf spectacular will provide its OpenAPI schema.
Later, the frontend can use that schema to generate TypeScript types. This reduces the chance that the frontend and backend silently develop different ideas of what an API response looks like.
Let the backend own the API contract
Django defines the API. OpenAPI describes it. The frontend can then generate types from that contract instead of relying on manually copied interfaces.
Do not add another service before it is needed
The backend also includes Django Q2 for background work.
For the first version I configured it to use the application's existing database rather than adding Redis immediately.
Redis may make sense later. Right now there is no workload that proves I need it.
Keep the first local stack small
React, Django and PostgreSQL are enough for the foundation. Additional infrastructure can be introduced when a real requirement justifies it.
Environment rules apply to the frontend too
The frontend uses the same separation between real local configuration and the configuration contract.
frontend ├── .env.local └── .env.example
VITE_API_BASE_URL=/api
VITE_APP_ENV=local
VITE_APP_VERSION=local
There is one important difference on the frontend.
Values exposed through Vite can become part of the browser bundle. Anything beginning with VITE_ should therefore be treated as public configuration.
Database passwords, application secrets and private API credentials do not belong there.
Add quality checks before there is much code
Coding standards are easier to introduce when the codebase is still small.
The backend uses Ruff for Python linting and formatting.
[tool.ruff]
target-version = "py312"
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "I", "UP"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
Ruff is also connected to Git through pre commit.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.5
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
The current Vite scaffold already included Oxlint. I kept it and added Prettier for formatting.
repos:
- repo: local
hooks:
- id: oxlint
name: oxlint
entry: npm run lint
language: system
pass_filenames: false
- id: prettier
name: prettier
entry: npm run format:check
language: system
pass_filenames: false
oxlint.....................................Passed
prettier...................................Passed
ruff check.................................Passed
ruff format................................Passed
Both repositories finished the setup with clean initial commits and clean working trees.
What exists after the first setup session
There is still no business feature.
There is no custom Django model, no real application API and no business screen.
That is intentional.
The frontend runs.
The backend runs.
Django connects to PostgreSQL 16.
PostgreSQL has persistent local storage.
Secrets stay outside Git.
The frontend has a clean local path to the API.
Both repositories have automatic quality checks.
That is enough for the first foundation.
Local does not need to look exactly like Azure
I am not trying to reproduce Azure on my laptop.
I am trying to keep the important architecture decisions consistent.
| Layer | Local | Azure |
|---|---|---|
| Frontend | React and Vite on Windows | Azure Static Web Apps |
| Backend | Django in a Python virtual environment | Azure App Service |
| Database | PostgreSQL 16 in Docker | Azure Database for PostgreSQL |
| Configuration | Local environment files | Azure application configuration |
| Source | Two local Git repositories | Two GitHub repositories |
The runtime changes between environments. The basic shape of the application does not.
The local environment is part of the architecture
It is easy to describe all of this as project setup.
I see it as the first architecture work.
Repository boundaries affect deployment. The database choice affects development and migration behaviour. Environment rules affect security. Dependency choices affect how long the application can be maintained. Quality checks affect every commit that follows.
None of these decisions produces a useful business feature on the first day.
Their value appears later because they make the next hundred decisions easier.
That is what I wanted from the local foundation.
Build locally in a way that makes the path to Azure boring.