Getting a Django app running locally is the easy part. Getting it deployed reliably with tests running automatically, secrets handled safely, migrations applied without downtime, and a rollback path when something goes wrong is where a lot of projects still rely on manual steps and crossed fingers. I've used Azure DevOps to own deployment on a Django platform serving both web and mobile clients, and here's the pipeline structure that's held up well.
The shape of the pipeline
A solid CI/CD pipeline for Django breaks into distinct stages, each of which should fail loudly and stop the pipeline rather than letting a broken build limp forward:
- Build : install dependencies, run linting
- Test : Run the test suite against a real database, not sqlite-in-memory shortcuts that hide production bugs
- Migrate : apply database migrations safely
- Deploy : Push the new build to the target environment
- Verify : A basic health check to confirm the deployment actually worked before calling it done
Here's what that looks like as an Azure Pipelines YAML file:
# azure-pipelines.yml
trigger:
branches:
include:
- main
pool:
vmImage: "ubuntu-latest"
stages:
- stage: Build
jobs:
- job: BuildAndLint
steps:
- task: UsePythonVersion@0
inputs:
versionSpec: "3.12"
- script: |
pip install -r requirements.txt
pip install flake8
flake8 .
displayName: "Install dependencies and lint"
- stage: Test
dependsOn: Build
jobs:
- job: RunTests
steps:
- script: |
python manage.py test
displayName: "Run test suite"
env:
DATABASE_URL: $(TEST_DATABASE_URL)
- stage: Deploy
dependsOn: Test
condition: succeeded()
jobs:
- deployment: DeployToAzure
environment: "production"
strategy:
runOnce:
deploy:
steps:
- script: |
python manage.py migrate --noinput
displayName: "Apply migrations"
env:
DATABASE_URL: $(PROD_DATABASE_URL)
- task: AzureWebApp@1
inputs:
azureSubscription: "$(AZURE_SERVICE_CONNECTION)"
appType: "webAppLinux"
appName: "$(APP_NAME)"
package: "$(Pipeline.Workspace)/**/*.zip"
The key structural decision here is dependsOn and condition: succeeded(); deployment simply cannot run if tests fail. This sounds obvious, but it's exactly the guardrail that gets skipped under deadline pressure if it's not built into the pipeline itself rather than left as a manual check.
Secrets: never in the YAML, never in the repo
Database URLs, API keys for partner integrations, and Django's SECRET_KEY should never appear directly in your pipeline YAML or in settings.py. Azure DevOps has variable groups and Azure Key Vault integration specifically for this:
variables:
- group: "production-secrets" # linked to an Azure Key Vault or secure variable group
Reference secrets in scripts as environment variables (as shown in the env: blocks above), and mark them as "secret" in the variable group UI so they're masked in pipeline logs. If you're integrating with partner APIs, a payment provider, a bank, or anything handling KYC, this isn't optional. A leaked key in a build log is a real incident, not a hypothetical one.
Migrations: the part that actually needs the most care
Running migrate automatically in a deploy pipeline is convenient, but it's also the step most likely to cause real damage if done carelessly. A migration that locks a large table, or one that's not backward-compatible with the currently-running code during a rolling deployment, can cause an outage rather than prevent one.
A few practices that matter here:
- Avoid migrations that both add a NOT NULL column and remove the old one in the same deploy; split it into multiple deploys: add the column as nullable first, backfill data, then tighten the constraint in a later release. This avoids a window where old code (still running during a rolling deploy) breaks against the new schema.
-
Run migrations as a separate step before the new application code goes live, not simultaneously; the pipeline above does this by running
migratebefore theAzureWebAppdeploy task, so the database is ready before traffic hits the new code. - Test migrations against a realistic copy of production data volume in staging, not just a small test database; a migration that runs instantly on 1,000 rows can lock a table for minutes on a table with millions.
Separate environments, separate pipelines (or stages)
For a platform serving real users, a single "push to main, deploy to production" pipeline is risky. A staging environment mirroring production as closely as practical catches problems before they reach real users:
stages:
- stage: DeployStaging
jobs:
- deployment: DeployToStaging
environment: "staging"
strategy:
runOnce:
deploy:
steps:
- task: AzureWebApp@1
inputs:
appName: "$(STAGING_APP_NAME)"
- stage: DeployProduction
dependsOn: DeployStaging
condition: succeeded()
jobs:
- deployment: DeployToProduction
environment: "production"
# Azure DevOps environments support approval gates here
# require manual sign-off before production deploy proceeds
Azure DevOps environments support approval gates; you can require a manual approval step before the production stage runs, which is worth having on anything touching real user data or money, even if most of your pipeline is fully automated.
Health checks and rollback readiness
A deploy that "succeeds" according to the pipeline but leaves the app crash-looping isn't actually a successful deploy. Add a basic verification step after deployment:
- script: |
curl -f https://$(APP_NAME).azurewebsites.net/healthz/ || exit 1
displayName: "Verify deployment health"
Pair this with a simple /healthz/ Django view that checks database connectivity, not just that the process is running:
from django.http import JsonResponse
from django.db import connections
def health_check(request):
try:
connections["default"].cursor()
return JsonResponse({"status": "ok"})
except Exception:
return JsonResponse({"status": "error"}, status=503)
And know your rollback path before you need it, whether that's Azure App Service's deployment slots (swap back to the previous slot instantly) or simply keeping the previous build artifact ready to redeploy. Deciding this during an incident, under pressure, is much worse than deciding it in advance.
A short checklist
- Pipeline stages are ordered so deployment is gated on tests passing, not run in parallel or independently
- Secrets live in Azure Key Vault or secure variable groups, never in YAML or source code
- Migrations run as a distinct step before new code goes live, with backward-compatible migration patterns for zero-downtime deploys
- Staging exists and mirrors production closely enough to catch real issues
- Production deploys have an approval gate for anything touching money or user data
- A post-deploy health check verifies the app is actually working, not just that the deploy command exited successfully
- A rollback path is decided in advance, not improvised during an incident
None of this is exotic, but each piece tends to get skipped under time pressure until the day a bad migration or a leaked secret makes it very clear why it mattered. Building it into the pipeline once means it's enforced every time, not just when someone remembers to check.
Top comments (0)