DEV Community

Cover image for Deploying Django with Azure DevOps: A Practical CI/CD Pipeline
Josh Perspective
Josh Perspective

Posted on

Deploying Django with Azure DevOps: A Practical CI/CD Pipeline

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:

  1. Build : install dependencies, run linting
  2. Test : Run the test suite against a real database, not sqlite-in-memory shortcuts that hide production bugs
  3. Migrate : apply database migrations safely
  4. Deploy : Push the new build to the target environment
  5. 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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 migrate before the AzureWebApp deploy 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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)