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.
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.
From a local foundation to two cloud environments
The local architecture already had three clear responsibilities.
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.
Automatic deployment
Every accepted change to the main branch should be deployed so it can be tested in a shared cloud environment.
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.
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.
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.
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.
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
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.
project_dev
Production received another.
project_prod
The important point is that these are not two schemas inside one shared development database. They are environment boundaries.
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.
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.
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.
gunicorn config.wsgi
Azure application settings hold the environment specific 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.
DEBUG=False
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.
GET /api/health/live/
{"status": "ok"}
This verifies that Django is running and serving HTTP requests.
A second endpoint checks readiness.
GET /api/health/ready/
{"status": "ok"}
The readiness check also touches the database.
REQUEST │ ▼ AZURE APP SERVICE │ ▼ DJANGO │ ▼ POSTGRESQL │ ▼ READY
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.
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.
python manage.py createsuperuser
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.
/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.
pip install whitenoise
The middleware sits immediately after Django's security middleware.
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.
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.
python manage.py collectstatic --noinput
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:
python manage.py collectstatic --noinput && gunicorn config.wsgi
The deployment lifecycle is now explicit.
APP SERVICE STARTS
│
▼
COLLECTSTATIC
│
▼
STATIC FILES READY
│
▼
GUNICORN
│
▼
DJANGO
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.
REACT SOURCE
│
▼
npm run build
│
▼
dist/
│
▼
AZURE STATIC WEB APPS
The frontend and backend remain separate deployment units exactly as they were locally.
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:
/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.
{
"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.
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.
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.
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL || '/api'
For development, the GitHub workflow injects a development value.
VITE_API_BASE_URL=https://devapi.example.com/api
The production workflow uses a separate value.
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.
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.
https://dev.example.com
│
│ POST /api/auth/login/
▼
https://devapi.example.com
Before sending that POST, the browser issued a CORS preflight request.
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.
CORS_ALLOWED_ORIGINS = env.list(
"CORS_ALLOWED_ORIGINS",
default=["http://localhost:5173"],
)
The middleware is placed before Django's common middleware.
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.
$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.
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
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.
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.
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.
SECURE_PROXY_SSL_HEADER = (
"HTTP_X_FORWARDED_PROTO",
"https",
)
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.
COMMIT │ ▼ CI ├── Lint ├── Format ├── Test └── Build validation
Deployment answers a different question.
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.
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.
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.
on:
workflow_dispatch:
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.
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.
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.
Checking formatting...
[warn] .github/workflows/production.yml
[warn] Code style issues found in the above file.
The fix was straightforward.
npx prettier --write .github/workflows/production.yml
npm run format:check
The workflow file passed the same formatting checks as the rest of the frontend repository.
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:
503 Service Unavailable
The useful information came from the App Service Log Stream.
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:
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.
The production liveness endpoint returned {"status": "ok"} and the readiness endpoint confirmed database connectivity.
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.
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.
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.
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.
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.
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:
400 Bad Request
This was actually progress.
The request had already passed several layers.
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.
ALLOWED_HOSTS=
<azure-app-service-host>,api.example.com
After restarting the application, the health endpoint worked on the final API domain.
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:
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.
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.
https://example.com
│
│ POST /api/auth/login/
▼
https://api.example.com
│
▼
DJANGO
│
▼
PRODUCTION POSTGRESQL
The final browser request returned:
Request URL
https://api.example.com/api/auth/login/
Request method
POST
Status code
200 OK
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.
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.
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 add .
git commit -m "Implement feature"
git push origin main
That push triggers the development path.
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.
TESTED COMMIT
│
▼
RUN PROD BACKEND WORKFLOW
+
RUN PROD FRONTEND WORKFLOW
│
▼
PRODUCTION
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.
Django Admin had no CSS
The application was running, but static files had not been collected before Gunicorn started.
Startup order matters
Infrastructure can be configured correctly and still fail if required preparation happens at the wrong point in the lifecycle.
Browser login failed with CORS
The frontend and backend worked independently, but the browser did not trust the cross-origin request.
The browser is part of the architecture
When an SPA and API use separate origins, browser security rules become an explicit system boundary.
Django Admin returned CSRF 403
The request reached Django, but the HTTPS origin and reverse proxy context had not yet been fully trusted.
CORS and CSRF solve different problems
Allowing frontend JavaScript to call an API is different from protecting cookie-backed state-changing requests.
The custom API domain returned 400
DNS and HTTPS worked, but Django did not yet recognize the hostname.
An error can prove earlier layers succeeded
A Django 400 meant the request had already crossed DNS, TLS, Azure routing and the application server.
Production returned 503
The deployment package reached Azure, but Django was unavailable because the Python runtime dependencies were not ready.
Deployment and runtime health are separate
A successful upload does not prove that the application can start, import its dependencies or serve requests.
CI failed after editing a workflow
The deployment logic was valid, but the generated YAML did not satisfy the repository's formatting rules.
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.
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.
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
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.
DEV ├── Unfinished features ├── Test records ├── Experimental migrations ├── Temporary configuration └── Frequent deployments
Production has a different 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.
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.
CODE READY
│
▼
TESTED IN DEV
│
▼
HUMAN DECISION
│
▼
RUN WORKFLOW
│
▼
AUTOMATED PROD DEPLOYMENT
That is a useful distinction.
The final architecture
The local system from Part 1 now has a complete path into Azure.
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.
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.
LAPTOP ├── React ├── Django └── PostgreSQL
It now has a controlled path from development to production.
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.