Writing/One-person data team
Article · Django · React

From Laptop to Azure, Part 2: Taking Django + React from DEV to Production

Building a repeatable Azure deployment path for Django and React, with automatic DEV deployments, deliberate PROD releases, PostgreSQL, custom domains and HTTPS.

In the first part of this series, I focused on building the local foundation for a modern Django and React application. The frontend and backend were kept as separate deployment units, PostgreSQL was used from the first migration, configuration lived outside the codebase, and the local architecture was designed with Azure in mind.

The next step was to take that foundation into the cloud.

I did not want to simply deploy the application once and call it production ready. I wanted a repeatable deployment path with a development environment, a separate production environment, automated validation and a deliberate promotion process.

Deployment path
LOCAL
├── React + TypeScript
├── Django REST API
└── PostgreSQL
       │
       ▼
GITHUB
├── Source control
├── CI
└── Deployment workflows
       │
       ▼
DEV
├── Azure Static Web Apps
├── Azure App Service
└── Azure Database for PostgreSQL
       │
       ▼
MANUAL PROMOTION
       │
       ▼
PROD
├── Azure Static Web Apps
├── Azure App Service
└── Azure Database for PostgreSQL

The goal was straightforward.

Push normally to development. Promote deliberately to production.

From a local foundation to two cloud environments

The local architecture already had three clear responsibilities.

Application boundary
FRONTEND
React + TypeScript
        │
        ▼
BACKEND
Django REST API
        │
        ▼
DATABASE
PostgreSQL

I wanted those same boundaries to remain visible in Azure.

The frontend would be hosted independently from the API. The API would remain the only application layer that talks to PostgreSQL. Development and production would run the same code, but they would not share databases, secrets, hostnames or deployment triggers.

Development

Automatic deployment

Every accepted change to the main branch should be deployed so it can be tested in a shared cloud environment.

Production

Deliberate deployment

Production should use the same tested source code, but deployment should require an explicit release decision.

This resulted in two independent Azure environments.

Cloud environments
DEV
├── dev.example.com
├── devapi.example.com
└── Development PostgreSQL

PROD
├── example.com
├── api.example.com
└── Production PostgreSQL

Design the Azure resource layout first

Before creating resources I established a naming pattern.

Azure environments become difficult to read surprisingly quickly when every service follows a different naming convention.

I used names conceptually similar to these.

Resource naming
project-web-dev-cac-001
project-api-dev-cac-001
project-pg-dev-cac-001

project-web-prod-cac-001
project-api-prod-cac-001
project-pg-prod-cac-001

Each segment carries information.

Naming structure
project-api-prod-cac-001
│       │    │    │   │
│       │    │    │   └── Instance number
│       │    │    └────── Canada Central
│       │    └─────────── Production
│       └──────────────── API / backend
└──────────────────────── Project

The exact abbreviations are not important. Consistency is.

I also separated the environments into different resource groups.

Resource groups
rg-project-dev
├── PostgreSQL Flexible Server
├── App Service Plan
├── Django App Service
└── Static Web App

rg-project-prod
├── PostgreSQL Flexible Server
├── App Service Plan
├── Django App Service
└── Static Web App
Architecture decision

Keep DEV and PROD operationally separate

The two environments share source code, but they do not share databases, runtime configuration or deployment triggers.

Move PostgreSQL to Azure first

The local application already used PostgreSQL, so moving the database to Azure did not require changing database technology.

I used Azure Database for PostgreSQL Flexible Server.

The development environment received its own database.

DEV database
project_dev

Production received another.

PROD database
project_prod

The important point is that these are not two schemas inside one shared development database. They are environment boundaries.

Data boundary

Production data never depends on the development database

Development can contain test records, experimental migrations and unfinished work without contaminating production data.

Keep the Django database configuration environment based

The application code does not know whether PostgreSQL is running in Docker, Azure development or Azure production.

It reads the connection from configuration.

Environment contract
POSTGRES_DB=
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_HOST=
POSTGRES_PORT=5432

Locally, the host can point to PostgreSQL running in Docker.

In Azure, the same Django settings can point to a Flexible Server hostname.

config/settings.py
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": env("POSTGRES_DB"),
        "USER": env("POSTGRES_USER"),
        "PASSWORD": env("POSTGRES_PASSWORD"),
        "HOST": env("POSTGRES_HOST"),
        "PORT": env("POSTGRES_PORT", default="5432"),
    }
}

The code stays the same.

The environment tells the application where it is running.

Deploy Django to Azure App Service

The backend runs on Azure App Service for Linux with Python 3.12.

The application remains a normal Django WSGI application and is served by Gunicorn.

Application server
gunicorn config.wsgi

Azure application settings hold the environment specific configuration.

Backend runtime configuration
DJANGO_SECRET_KEY
DEBUG
ALLOWED_HOSTS

POSTGRES_DB
POSTGRES_USER
POSTGRES_PASSWORD
POSTGRES_HOST
POSTGRES_PORT

CORS_ALLOWED_ORIGINS
CSRF_TRUSTED_ORIGINS

Production runs with debug mode disabled.

Production
DEBUG=False
Security rule

Runtime secrets stay outside the repository

The repository contains the configuration contract. Azure contains the production values.

Add health checks before debugging features

A deployment can complete successfully while the application itself is unhealthy.

I wanted very small endpoints that could answer basic infrastructure questions without depending on a business feature.

Liveness endpoint
GET /api/health/live/
Result
{"status": "ok"}

This verifies that Django is running and serving HTTP requests.

A second endpoint checks readiness.

Readiness endpoint
GET /api/health/ready/
Result
{"status": "ok"}

The readiness check also touches the database.

Readiness path
REQUEST
   │
   ▼
AZURE APP SERVICE
   │
   ▼
DJANGO
   │
   ▼
POSTGRESQL
   │
   ▼
READY
Verified

The backend process was running and the application could establish a real connection to Azure PostgreSQL.

Run migrations against the cloud database

A newly created production PostgreSQL database contains no Django schema.

After confirming database connectivity, I applied the migrations.

Azure application shell
python manage.py migrate --noinput

This created the Django authentication, administration, session, token and application tables in the Azure database.

I also created the initial production administrator.

Azure application shell
python manage.py createsuperuser
Verified

Django Admin could authenticate against the production database.

The Django Admin loaded without its CSS

One of the first deployment issues was immediately visible.

The Django Admin page loaded, but it looked like raw HTML.

The forms worked. The CSS and JavaScript did not.

This was not an authentication problem and it was not an Azure routing problem.

The missing files lived under Django's static file system.

Observed request
/admin/
   │
   ├── HTML          OK
   │
   └── /static/admin/css/base.css
                      │
                      └── 404

Use WhiteNoise for Django static files

I added WhiteNoise to the backend so the Django application could serve its collected static files efficiently.

Python
pip install whitenoise

The middleware sits immediately after Django's security middleware.

config/settings.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    ...
]

The static configuration points Django at a deployment directory.

config/settings.py
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"

STORAGES = {
    "default": {
        "BACKEND": "django.core.files.storage.FileSystemStorage",
    },
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage",
    },
}

Locally, static collection worked.

PowerShell
python manage.py collectstatic --noinput
Result
154 static files copied
145 post-processed

But Azure still returned a 404 for the admin stylesheet.

The problem was startup order

WhiteNoise was configured correctly. The files simply had not been collected in the deployed environment before Gunicorn started.

The final App Service startup command became:

Azure App Service startup command
python manage.py collectstatic --noinput && gunicorn config.wsgi

The deployment lifecycle is now explicit.

Backend startup
APP SERVICE STARTS
       │
       ▼
COLLECTSTATIC
       │
       ▼
STATIC FILES READY
       │
       ▼
GUNICORN
       │
       ▼
DJANGO
Deployment lesson

Correct configuration is not enough if lifecycle order is wrong

The files and middleware were both present. The missing step was making static collection part of the application startup process.

Deploy the React frontend with Azure Static Web Apps

The frontend is a Vite application, so production deployment ultimately means producing a static bundle.

Frontend build
REACT SOURCE
      │
      ▼
npm run build
      │
      ▼
dist/
      │
      ▼
AZURE STATIC WEB APPS

The frontend and backend remain separate deployment units exactly as they were locally.

Architecture continuity

Local boundaries became deployment boundaries

The frontend repository deploys to Static Web Apps. The backend repository deploys to App Service. Neither deployment requires packaging the other application.

Configure React Router for direct navigation

A single page application introduces another hosting detail.

Navigating through React to a route such as:

Application route
/intake/new

works because the React Router is already running in the browser.

Refreshing that page is different. Azure receives a direct request for /intake/new.

There is no physical file with that name.

I added a Static Web Apps navigation fallback.

staticwebapp.config.json
{
  "navigationFallback": {
    "rewrite": "/index.html",
    "exclude": [
      "/assets/*",
      "/*.{css,js,png,jpg,jpeg,gif,svg,ico,webp,woff,woff2}"
    ]
  }
}

This tells Azure that application routes should return the React entry point while real static assets should still be served normally.

Verified

A protected nested React route continued to load correctly after a full browser refresh.

Give each frontend build the correct API

The frontend needs to know which backend it should call.

Development and production cannot share the same API base URL.

Environment API mapping
DEV FRONTEND
dev.example.com
      │
      ▼
devapi.example.com/api

PROD FRONTEND
example.com
      │
      ▼
api.example.com/api

The frontend reads the API base from Vite.

API client
const API_BASE_URL =
  import.meta.env.VITE_API_BASE_URL || '/api'

For development, the GitHub workflow injects a development value.

GitHub repository variable
VITE_API_BASE_URL=https://devapi.example.com/api

The production workflow uses a separate value.

Production repository variable
VITE_API_BASE_URL_PROD=https://api.example.com/api

Vite environment variables are build-time configuration.

Changing the variable does not change an already deployed JavaScript bundle. The frontend must be rebuilt.

Frontend configuration rule

The environment is chosen when the frontend is built

A DEV build contains the DEV API URL. A PROD build contains the PROD API URL.

CORS exposed the frontend and backend boundary

At one point the frontend worked, the backend worked, PostgreSQL worked and direct API requests worked.

Browser login still failed.

The frontend and backend were on different origins.

Cross-origin request
https://dev.example.com
          │
          │ POST /api/auth/login/
          ▼
https://devapi.example.com

Before sending that POST, the browser issued a CORS preflight request.

Browser preflight
OPTIONS /api/auth/login/

The browser was effectively asking the API whether this frontend origin was allowed to send the request.

Configure django-cors-headers

The backend uses django-cors-headers.

The approved browser origins are loaded from the environment.

config/settings.py
CORS_ALLOWED_ORIGINS = env.list(
    "CORS_ALLOWED_ORIGINS",
    default=["http://localhost:5173"],
)

The middleware is placed before Django's common middleware.

Middleware order
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    ...
]

Test the actual preflight instead of guessing

The browser message was useful, but I wanted to test the HTTP behaviour directly.

I reproduced the preflight request from PowerShell.

PowerShell
$response = Invoke-WebRequest `
  -Method Options `
  -Uri "https://devapi.example.com/api/auth/login/" `
  -Headers @{
    Origin = "https://dev.example.com"
    "Access-Control-Request-Method" = "POST"
    "Access-Control-Request-Headers" = "content-type"
  } `
  -UseBasicParsing

$response.StatusCode
$response.Headers

A correct response looked like this.

Preflight result
200

Access-Control-Allow-Origin:
https://dev.example.com

Access-Control-Allow-Methods:
DELETE, GET, OPTIONS, PATCH, POST, PUT

Access-Control-Allow-Headers:
accept, authorization, content-type, user-agent,
x-csrftoken, x-requested-with
Verified

Django was receiving the preflight and explicitly allowing the development frontend origin.

CORS and CSRF are different problems

Once the API login was working, Django Admin exposed another boundary.

The admin page loaded, but submitting its login form returned a CSRF error.

Django response
403 Forbidden

CSRF verification failed.
Request aborted.

CORS controls whether browser code on one origin can call another origin.

CSRF protection defends state-changing requests that rely on trusted browser state such as cookies.

The Django Admin uses the second model.

Trust the HTTPS origin behind Azure

The application loads its trusted CSRF origins from the environment.

config/settings.py
CSRF_TRUSTED_ORIGINS = env.list(
    "CSRF_TRUSTED_ORIGINS",
    default=[],
)

Azure App Service also sits behind a reverse proxy.

Django therefore needs to understand the forwarded HTTPS protocol.

config/settings.py
SECURE_PROXY_SSL_HEADER = (
    "HTTP_X_FORWARDED_PROTO",
    "https",
)
Verified

Django Admin could authenticate correctly over the Azure HTTPS endpoint.

Continuous integration and deployment are different jobs

Before Azure deployment, both repositories already had CI.

The CI workflow answers a quality question.

Continuous integration
COMMIT
   │
   ▼
CI
├── Lint
├── Format
├── Test
└── Build validation

Deployment answers a different question.

Deployment
VALID SOURCE
      │
      ▼
TARGET ENVIRONMENT
├── DEV
└── PROD

Keeping those concepts separate made the workflows easier to reason about.

Let DEV deploy automatically

Development exists to provide rapid shared feedback.

For that environment, automatic deployment is useful.

Development delivery
git push main
      │
      ▼
GitHub CI
      │
      ▼
DEV backend deployment
      +
DEV frontend deployment
      │
      ▼
Test in DEV

A normal push updates the shared development environment without requiring another manual deployment step.

Do not make production deploy on every push

When Azure created the initial production workflows, they contained both an automatic push trigger and a manual trigger.

Initial Azure workflow trigger
on:
  push:
    branches:
      - main
  workflow_dispatch:

That would make every push to main a production deployment.

That was not the release model I wanted.

The production workflows were changed to manual only.

Production workflow trigger
on:
  workflow_dispatch:
Release decision

Automate the deployment, not the decision to deploy

Production deployment itself remains automated. The only deliberate step is deciding when the tested code should be promoted.

Build production with production configuration

The production frontend workflow does not simply copy the development bundle.

It performs its own build using the production API URL.

Production frontend workflow
name: Deploy Frontend to Production

on:
  workflow_dispatch:

jobs:
  build_and_deploy_job:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '24'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build frontend
        run: |
          npm run build
          cp staticwebapp.config.json dist/
        env:
          VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL_PROD }}

      - name: Deploy to Azure Static Web Apps
        uses: Azure/static-web-apps-deploy@v1
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
          action: "upload"
          app_location: "/dist"
          api_location: ""
          output_location: ""
          skip_app_build: true

This gives the same source code two environment-specific builds.

Frontend build targets
MAIN BRANCH
    │
    ├── DEV workflow
    │      └── VITE_API_BASE_URL
    │             ↓
    │      devapi.example.com
    │
    └── PROD workflow
           └── VITE_API_BASE_URL_PROD
                  ↓
           api.example.com

Treat workflow files as production code

One frontend CI run failed immediately after an Azure-generated workflow was edited.

The deployment logic was fine.

The YAML file simply did not satisfy the repository's Prettier rules.

CI failure
Checking formatting...

[warn] .github/workflows/production.yml
[warn] Code style issues found in the above file.

The fix was straightforward.

PowerShell
npx prettier --write .github/workflows/production.yml
npm run format:check
Verified

The workflow file passed the same formatting checks as the rest of the frontend repository.

Engineering rule

CI/CD configuration is part of the application

Workflow files can break deployments. They deserve version control, review and automated quality checks just like application code.

The first production backend deployment returned 503

The first production backend deployment produced an interesting failure.

The deployment process reported that the package had been copied successfully, but the application endpoint returned:

Runtime response
503 Service Unavailable

The useful information came from the App Service Log Stream.

Azure runtime log
ModuleNotFoundError: No module named 'django'

Could not find build manifest
Could not find virtual environment
Could not find package directory

The source files existed.

The application runtime did not have the installed Python dependencies it needed.

The App Service configuration already included:

Azure application setting
SCM_DO_BUILD_DURING_DEPLOYMENT=1

This enables Azure's Oryx build process during deployment.

After the initial App Service configuration had settled, rerunning the deployment successfully created the runtime environment.

Verified

The production liveness endpoint returned {"status": "ok"} and the readiness endpoint confirmed database connectivity.

Deployment lesson

A successful package transfer is not the same as a healthy application

Deployment, dependency installation, application startup and runtime health should be verified independently.

Move from Azure hostnames to application domains

At this point both environments worked using Azure generated hostnames.

Those addresses are useful for infrastructure verification, but they should not be the public identity of the application.

I mapped custom domains for both environments.

Final domain structure
DEV
├── dev.example.com
└── devapi.example.com

PROD
├── example.com
└── api.example.com

The domain remained with an external DNS provider. Azure only needed the appropriate DNS records.

Connect the frontend domain

Azure Static Web Apps provided the target hostname for a CNAME record.

Development frontend DNS
Type:  CNAME
Host:  dev
Value: <azure-static-web-app>.azurestaticapps.net

After DNS propagation, Azure validated the record and attached the custom hostname.

The production frontend followed the same principle for the final public domain.

Verified

The application frontend loaded successfully over HTTPS using its final domain.

Connect the API domain

Azure App Service required both routing and ownership validation.

The DNS configuration looked conceptually like this.

Production API DNS
CNAME
Host: api
Value: <azure-app-service>.azurewebsites.net

TXT
Host: asuid.api
Value: <Azure verification value>

The CNAME routes the hostname.

The TXT record allows Azure to verify that the domain owner intended to attach that hostname to the App Service.

Let Azure manage the TLS certificate

Once domain ownership was validated, I used an App Service Managed Certificate with SNI SSL.

HTTPS binding
api.example.com
      │
      ▼
Azure App Service
      │
      ├── Domain validated
      ├── Managed certificate
      └── SNI SSL binding

No manually purchased certificate needed to be installed or renewed.

Azure owns that certificate lifecycle.

DNS working did not mean Django trusted the hostname

After the production API domain started reaching Azure, the browser returned:

Response
400 Bad Request

This was actually progress.

The request had already passed several layers.

What the 400 already proved
DNS                OK
      │
      ▼
TLS CERTIFICATE    OK
      │
      ▼
AZURE ROUTING      OK
      │
      ▼
GUNICORN           OK
      │
      ▼
DJANGO             REJECTED HOST

The new hostname had not yet been added to Django's allowed hosts.

Azure application setting
ALLOWED_HOSTS=
<azure-app-service-host>,api.example.com

After restarting the application, the health endpoint worked on the final API domain.

Verified

https://api.example.com/api/health/live/ returned a successful response.

Update CORS and CSRF for the final domains

The custom domains also needed to become part of Django's trust configuration.

The final production settings were conceptually similar to:

Production host configuration
ALLOWED_HOSTS=
<azure-backend-host>,api.example.com

CORS_ALLOWED_ORIGINS=
<azure-frontend-origin>,https://example.com

CSRF_TRUSTED_ORIGINS=
<azure-backend-origin>,https://api.example.com

I initially kept the Azure generated hostnames alongside the custom domains as fallback addresses during verification.

The production frontend variable was then changed to the final API address.

Production frontend API
VITE_API_BASE_URL_PROD=https://api.example.com/api

Because Vite configuration is embedded during the build, I manually ran the production frontend deployment again.

Verify the final production path

The most important production test was not whether the home page loaded.

It was whether the browser could authenticate through the final domain architecture.

Final authentication path
https://example.com
        │
        │ POST /api/auth/login/
        ▼
https://api.example.com
        │
        ▼
DJANGO
        │
        ▼
PRODUCTION POSTGRESQL

The final browser request returned:

Network verification
Request URL
https://api.example.com/api/auth/login/

Request method
POST

Status code
200 OK
Verified

The production frontend was calling the final production API domain, authentication succeeded and the API was using the production PostgreSQL database.

The finished deployment architecture

The development environment now works like this.

Development
DEVELOPER
    │
    ▼
git push main
    │
    ▼
GITHUB
├── CI
├── Backend DEV deployment
└── Frontend DEV deployment
    │
    ▼
dev.example.com
    │
    ▼
devapi.example.com
    │
    ▼
PostgreSQL DEV

The production environment works differently.

Production
TESTED MAIN BRANCH
        │
        ▼
MANUAL RELEASE DECISION
        │
        ├── Run backend PROD workflow
        └── Run frontend PROD workflow
        │
        ▼
example.com
        │
        ▼
api.example.com
        │
        ▼
PostgreSQL PROD

What happens when code changes now

The infrastructure becomes most useful when the normal development process becomes boring.

A future change begins locally.

Git
git add .
git commit -m "Implement feature"
git push origin main

That push triggers the development path.

Normal development flow
LOCAL CHANGE
     │
     ▼
MAIN BRANCH
     │
     ▼
CI
     │
     ▼
DEV DEPLOYMENT
     │
     ▼
TEST IN DEV

Production remains unchanged.

When the tested commit is ready for release, the production workflows are run manually.

Release flow
TESTED COMMIT
      │
      ▼
RUN PROD BACKEND WORKFLOW
      +
RUN PROD FRONTEND WORKFLOW
      │
      ▼
PRODUCTION
The source is shared. The environments are separate. Promotion is deliberate.

What broke and what it taught me

The most useful parts of the deployment were the moments when something failed.

Each failure identified a different system boundary.

Problem

Django Admin had no CSS

The application was running, but static files had not been collected before Gunicorn started.

Lesson

Startup order matters

Infrastructure can be configured correctly and still fail if required preparation happens at the wrong point in the lifecycle.

Problem

Browser login failed with CORS

The frontend and backend worked independently, but the browser did not trust the cross-origin request.

Lesson

The browser is part of the architecture

When an SPA and API use separate origins, browser security rules become an explicit system boundary.

Problem

Django Admin returned CSRF 403

The request reached Django, but the HTTPS origin and reverse proxy context had not yet been fully trusted.

Lesson

CORS and CSRF solve different problems

Allowing frontend JavaScript to call an API is different from protecting cookie-backed state-changing requests.

Problem

The custom API domain returned 400

DNS and HTTPS worked, but Django did not yet recognize the hostname.

Lesson

An error can prove earlier layers succeeded

A Django 400 meant the request had already crossed DNS, TLS, Azure routing and the application server.

Problem

Production returned 503

The deployment package reached Azure, but Django was unavailable because the Python runtime dependencies were not ready.

Lesson

Deployment and runtime health are separate

A successful upload does not prove that the application can start, import its dependencies or serve requests.

Problem

CI failed after editing a workflow

The deployment logic was valid, but the generated YAML did not satisfy the repository's formatting rules.

Lesson

Infrastructure configuration is code

Workflow files deserve the same formatting, validation and version control discipline as application source.

Debug the system one layer at a time

The deployment changed the way I think about troubleshooting.

Instead of asking one large question such as why does the site not work?, I can test the architecture in order.

Troubleshooting path
DOES DNS RESOLVE?
       │
       ▼
DOES TLS WORK?
       │
       ▼
DOES AZURE ROUTE THE REQUEST?
       │
       ▼
DOES GUNICORN START?
       │
       ▼
DOES DJANGO ACCEPT THE HOST?
       │
       ▼
DOES THE API ROUTE EXIST?
       │
       ▼
DOES CORS ALLOW THE BROWSER?
       │
       ▼
DOES AUTHENTICATION WORK?
       │
       ▼
CAN DJANGO REACH POSTGRESQL?

This makes error messages much more useful.

Errors as architectural evidence
CERTIFICATE ERROR
└── Request has not reached Django yet

400 FROM DJANGO
└── DNS, TLS, routing and web server probably worked

CORS ERROR
└── API may work perfectly outside the browser

403 CSRF
└── Django received the request but rejected its trust context

503
└── Application process or runtime may not be healthy

200 HEALTH CHECK
└── Application is serving requests

200 READINESS CHECK
└── Application can also reach its database
Troubleshooting principle

Test boundaries, not symptoms

The fastest way to understand a distributed application is to verify one architectural boundary at a time.

Why I kept development and production separate

It would have been cheaper and faster initially to create one backend, one frontend and one database.

That simplicity would not last.

Development naturally contains things that production should not automatically inherit.

Development activity
DEV
├── Unfinished features
├── Test records
├── Experimental migrations
├── Temporary configuration
└── Frequent deployments

Production has a different responsibility.

Production responsibility
PROD
├── Controlled releases
├── Production data
├── Production secrets
├── Stable configuration
└── Deliberate deployment

The separation is not only about preventing bad code from reaching production.

It also prevents environmental coupling.

Architecture decision

Share source code, not runtime state

DEV and PROD can run the same commit while keeping their data, credentials, domains and release lifecycle independent.

Why production deployment is manual

Manual production deployment does not mean the deployment itself is manual.

I do not copy files to servers or log into Azure to install a release.

The production workflow handles the deployment automatically.

The manual part is only the release decision.

Production release gate
CODE READY
    │
    ▼
TESTED IN DEV
    │
    ▼
HUMAN DECISION
    │
    ▼
RUN WORKFLOW
    │
    ▼
AUTOMATED PROD DEPLOYMENT

That is a useful distinction.

Automation should remove repetitive work without removing intentional control.

The final architecture

The local system from Part 1 now has a complete path into Azure.

End-to-end architecture
LOCAL DEVELOPMENT
├── React + TypeScript
├── Django
└── PostgreSQL in Docker
        │
        ▼
GITHUB
├── Frontend repository
├── Backend repository
├── CI
└── Deployment workflows
        │
        ▼
DEV
├── dev.example.com
│      └── Azure Static Web Apps
│
├── devapi.example.com
│      └── Azure App Service
│
└── Azure PostgreSQL DEV
        │
        ▼
TEST AND VERIFY
        │
        ▼
MANUAL PROMOTION
        │
        ▼
PROD
├── example.com
│      └── Azure Static Web Apps
│
├── api.example.com
│      └── Azure App Service
│
└── Azure PostgreSQL PROD

The important part was not the Azure screens

Azure Portal made it possible to create the infrastructure quickly.

The more important work was deciding what each piece should be allowed to do.

Architecture questions
Should DEV and PROD share a database?
└── No

Should every push deploy production?
└── No

Should React connect directly to PostgreSQL?
└── No

Should secrets live in Git?
└── No

Should the frontend know its environment?
└── Yes, through build-time configuration

Should runtime health be verified separately from deployment?
└── Yes

Should custom domains be introduced before Azure URLs work?
└── No

Once those decisions were made, most Azure screens became implementation details.

From laptop to production

The application started as a local workspace with two repositories and a PostgreSQL container.

Part 1
LAPTOP
├── React
├── Django
└── PostgreSQL

It now has a controlled path from development to production.

Part 2
LOCAL
   │
   ▼
GITHUB
   │
   ▼
CI
   │
   ▼
DEV
   │
   ▼
VERIFY
   │
   ▼
MANUAL PROMOTION
   │
   ▼
PROD

The result is not simply that the application is running in Azure.

The important result is that the deployment process itself now has an architecture.

A developer can make a change locally, push it, see it automatically deployed to development, verify it through the real cloud stack and then deliberately promote that same source to production.

The frontend, API and database remain separate.

Development and production remain separate.

Configuration determines the environment.

GitHub controls delivery.

Azure provides the runtime.

DNS gives the application stable public identities.

HTTPS protects the boundaries between them.

The goal was never just to get the application into Azure. The goal was to make the path from laptop to production predictable, testable and repeatable.