Post-Deploy Verification Checklist for Django

Verify a Django release after every deploy: smoke tests, release version checks, migrations, static files, background jobs, and latency monitoring.

Problem statement

A deploy script that exits 0 proves that commands ran, not that the site works. Deploys fail in ways scripts do not notice: workers restarted with old code, migrations applied but static files missing, a new environment variable absent, a Celery worker that never came back, or an endpoint returning 500 only for logged-in users.

The gap between "deployed" and "working" is where most release incidents live. Closing it takes a short, automated verification run after every deploy, plus a defined monitoring window during which someone watches whether the release made things worse.

This checklist defines what to verify after a Django deploy, how to automate most of it, and when to roll back.

Proving a release is healthyVersionnew build servingSmoke testscritical pathsSignalserrors, latencyDecisionkeep or roll backVerification is time-boxed: know in advance what would make you roll back.

Quick answer

  1. Confirm the running release matches the intended commit on every server or replica.
  2. Check process health: web workers, Celery workers, and Beat are running with the new code.
  3. Confirm migrations applied and no unapplied migrations remain.
  4. Run automated smoke tests against the deployed environment: health endpoint, key pages, login, one write flow, static assets.
  5. Verify background jobs process a test task and scheduled jobs are still registered.
  6. Watch error rate, latency, and queue depth for a defined window (for example 15 to 30 minutes).
  7. Roll back if predefined criteria are met; otherwise record the release as verified.

Checklist

1. Release identity

Expose the release version from the application, then check it:

expected=$(git rev-parse --short HEAD)
actual=$(curl -sI https://www.example.com/healthz/ | awk -F': ' 'tolower($1)=="x-app-release"{print $2}' | tr -d '\r')
[ "$expected" = "$actual" ] && echo "release $actual live" || echo "expected $expected, got ${actual:-none}"
is the new release actually serving?expected=$(git rev-parse--short HEAD)actual=$(curl -sI https://www.example.com/healthz/ \| awk -F': ' 'tolower($1)=="x-app-release"{print $2}')["$expected" = "$actual"]1231The commit you meant to deploy.2A header the app sets from its build.3Fails when an old process still answers.Check through the public URL, so every layer is includeddjango-deployment.com
Behind a load balancer, repeat it a few times to hit every instance.

With several servers or replicas, check each one directly (through private addresses or by repeating requests until all instances have answered). A mismatch means a server did not restart, an old process is still serving, or the deploy reached only part of the fleet.

2. Process health

systemctl is-active myapp celery-worker celery-beat nginx
systemctl show -p ActiveEnterTimestamp myapp | cut -d= -f2

Or for containers:

docker compose ps
docker inspect --format '{{.State.Health.Status}} {{.Config.Image}}' myapp-web-1

What to check:

  • services are active and started after the deploy timestamp
  • restart counts are not climbing (a crash loop can look "running" between restarts)
  • worker processes use the new release directory or image tag

3. Migrations

python manage.py showmigrations --plan | grep -c "\[ \]"   # expect 0 unapplied
python manage.py migrate --check && echo "no pending migrations"

migrate --check exits non-zero when migrations are missing, which makes it useful in a verification script. Also confirm the migration step ran once, not once per server, by checking deploy logs.

4. Smoke tests

A short script covering the paths that matter most, run against the real environment after every deploy:

#!/bin/bash
set -uo pipefail
BASE="${BASE:-https://www.example.com}"
fails=0

check() {
  local desc="$1" expected="$2" actual="$3"
  if [ "$actual" = "$expected" ]; then
    printf "ok   %-40s %s\n" "$desc" "$actual"
  else
    printf "FAIL %-40s expected %s got %s\n" "$desc" "$expected" "$actual"
    fails=$((fails + 1))
  fi
}

code() { curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$@"; }

check "health endpoint"        200 "$(code "$BASE/healthz/")"
check "home page"              200 "$(code "$BASE/")"
check "login page"             200 "$(code "$BASE/accounts/login/")"
check "admin login page"       200 "$(code "$BASE/admin/login/")"
check "unknown url is 404"     404 "$(code "$BASE/this-should-not-exist/")"
check "http redirects"         301 "$(code -I "http://www.example.com/")"

css=$(curl -s "$BASE/admin/login/" | grep -o '/static/[^"]*base[^"]*\.css' | head -1)
check "admin css"              200 "$(code "$BASE$css")"

api=$(curl -s --max-time 10 "$BASE/api/status/")
[ "$(printf '%s' "$api" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("status",""))' 2>/dev/null)" = "ok" ] \
  && echo "ok   api status" || { echo "FAIL api status: $api"; fails=$((fails + 1)); }

exit $fails
smoke tests in the first minuteHome page returns 200over HTTPSA database-backed page loadsreal queriesA static file returns 200hashed nameLogin and one form POSTCSRF and sessionsScripted, so they run the same way every timedjango-deployment.com
Use a dedicated test account for the login check.

Run it from CI at the end of the deploy job, so a failure marks the deploy red. Keep it fast (under a minute) so it runs every time.

5. Authenticated flow

Status codes on public pages miss problems that only affect logged-in users. Add one authenticated check with a dedicated test account:

  • log in through the form (fetching the CSRF token first)
  • load a page that requires authentication
  • perform one harmless write (for example updating a profile field to a test value)
  • log out

Keep the account's permissions minimal and its data isolated from real users. Exclude it from analytics and billing.

6. Background jobs

celery -A config inspect ping
celery -A config inspect registered | grep -c "tasks\."
python manage.py shell -c "
from core.tasks import ping_task
r = ping_task.delay()
print(r.get(timeout=30))
"

What to check:

  • workers respond and run the new code (task list includes newly added tasks)
  • a test task completes end to end
  • Beat is running exactly once and its heartbeat timestamp is recent
  • queue depth is not growing after the deploy

7. Static and media files

  • a hashed asset from the new release returns 200 with long cache headers
  • the admin renders styled
  • an existing uploaded file still loads (confirms media storage configuration is unchanged)

8. Data and integrations

For releases that change data handling or third-party integrations:

  • a test webhook from the provider is received and processed
  • outbound email sends (check the provider's activity log rather than a real inbox where possible)
  • payment or other critical integrations work in their test mode, or a small real transaction is verified in a controlled way

9. Monitoring window

For 15 to 30 minutes after the deploy, watch:

SignalWhat to look for
5xx rateany increase over the pre-deploy baseline
latency p95regressions on key endpoints
error trackernew issue types tagged with the new release
queue depth and task failuresbackground regressions
databaseconnection count, slow query changes, lock waits
logsnew warnings, especially django.security and deprecation warnings
watch before you walk awayT+0deploy finishedT+5 minerror rate vs baselineT+15 minlatency, queuesT+30 minscheduled jobs ranT+60 minwindow closedRollback is cheapest inside this windowdjango-deployment.com
Avoid deploying right before the person watching goes offline.

Annotate the deploy on dashboards so the comparison is easy.

10. Rollback criteria

Decide before the deploy what triggers a rollback, for example:

  • 5xx rate above 2 percent for 5 minutes
  • any failure in a critical flow (checkout, login, signup)
  • migration errors or data validation mismatches
  • latency p95 more than double the baseline for 10 minutes
decide the rollback triggers in advanceTriggerAction5xx rate> 2× baseline for 5 min✕roll backp95 latency> 1.5× baseline for 10 min!investigate, then decideFailed smoke testany critical path✕roll backJob failuresnew error type, rising!pause workers, decideAgreed thresholds remove the debate during an incidentdjango-deployment.com
Record which trigger fired; it improves the next set of thresholds.

If criteria are met, roll back first and investigate afterwards. Record the incident and what verification would have caught it earlier.

11. Record the result

Keep a short deploy log: release identifier, time, who deployed, verification result, and anything unusual. During an incident weeks later, "what changed and when" is the first question, and this log answers it in seconds.

Verify

Verification check:

  • the smoke test script runs automatically at the end of every deploy and fails the pipeline on error
  • the release version reported by production matches the deployed commit on all instances
  • no unapplied migrations remain
  • background workers run the new code and process a test task
  • error rate and latency return to baseline within the monitoring window

Explanation

Verification turns a deploy from an action into a confirmed outcome. Most of it is cheap: a handful of HTTP checks, a release header comparison, a test task, and a monitoring window. The value is disproportionate, because the failures it catches (half-updated fleets, missing configuration, broken assets, stopped workers) are exactly the ones that otherwise surface hours later through user reports.

Edge cases / notes

Canary and gradual rollouts

With canary deploys, verify the canary instances specifically before increasing traffic, and compare their metrics with the rest of the fleet.

Long-running migrations

When a migration takes a long time, verification happens after the schema change completes. Keep the application's previous version compatible in the meantime.

Scheduled deploys

If deploys happen automatically outside working hours, ensure verification failures page someone, or restrict automatic deploys to hours when people are available.

Wiring verification into the pipeline

Verification should be part of the deploy job, not a manual habit. A GitHub Actions deploy job might end with:

      - name: Deploy
        run: ./deploy/remote-deploy.sh

      - name: Wait for release to be live
        run: |
          expected="${GITHUB_SHA::7}"
          for i in $(seq 1 20); do
            actual=$(curl -sI https://www.example.com/healthz/ \
              | awk -F': ' 'tolower($1)=="x-app-release"{print $2}' | tr -d '\r')
            [ "$actual" = "$expected" ] && exit 0
            sleep 5
          done
          echo "release $expected not live (last seen: ${actual:-none})" >&2
          exit 1

      - name: Smoke tests
        run: BASE=https://www.example.com ./deploy/smoke-tests.sh

      - name: Notify
        if: failure()
        run: ./deploy/notify-failure.sh

A failed verification step marks the deploy red, notifies the team, and prompts either a fix or a rollback while the change is still fresh.

Verification for different deployment models

ModelAdditional checks
Single serverconfirm the service restarted and the socket is current
Several servers behind a load balancercheck the release header from each backend; confirm all targets healthy
Containers or Kubernetesrollout status completed, new image digest running, no crash loops
Blue-greenverify the idle color before switching, then verify again after cutover
PaaSplatform reports the new release active; confirm worker dynos or services restarted too

Keeping the checks honest

Smoke tests decay: endpoints change, the test account expires, an assertion silently passes because the page always returns 200. Review the script every few months:

  • does each check fail when the corresponding feature is broken? Test by pointing it at a deliberately broken staging environment.
  • do the checks cover flows that have caused incidents before?
  • are they fast enough that nobody is tempted to skip them?

Common mistakes

  • Treating a successful deploy script as verification.
  • Smoke tests that only check the homepage.
  • No authenticated check, so logged-in breakage is missed.
  • Skipping verification for "small" deploys, which are frequently the ones that break configuration.

Next steps

FAQ

What should a Django post-deploy check include?

Release version confirmation, service health, migration status, smoke tests for key pages and one authenticated write, background job checks, static asset checks, and a short monitoring window.

How do I know which release is running in production?

Expose the release identifier through a response header or health endpoint and compare it with the deployed commit. Check every server or replica, not just one response.

How long should I monitor after a deploy?

Long enough to see real traffic patterns, typically 15 to 30 minutes for a busy site, longer for releases that change data handling or background processing.

Should smoke tests run against production?

Yes, with read-only checks plus one safe authenticated write using a dedicated test account. Production is the only environment that proves the release works for users.

Learning path · step 10 of 14

Releases without downtime

Keep going