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.
Quick answer
- Confirm the running release matches the intended commit on every server or replica.
- Check process health: web workers, Celery workers, and Beat are running with the new code.
- Confirm migrations applied and no unapplied migrations remain.
- Run automated smoke tests against the deployed environment: health endpoint, key pages, login, one write flow, static assets.
- Verify background jobs process a test task and scheduled jobs are still registered.
- Watch error rate, latency, and queue depth for a defined window (for example 15 to 30 minutes).
- 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}"
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
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
200with 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:
| Signal | What to look for |
|---|---|
| 5xx rate | any increase over the pre-deploy baseline |
| latency p95 | regressions on key endpoints |
| error tracker | new issue types tagged with the new release |
| queue depth and task failures | background regressions |
| database | connection count, slow query changes, lock waits |
| logs | new warnings, especially django.security and deprecation warnings |
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
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
| Model | Additional checks |
|---|---|
| Single server | confirm the service restarted and the socket is current |
| Several servers behind a load balancer | check the release header from each backend; confirm all targets healthy |
| Containers or Kubernetes | rollout status completed, new image digest running, no crash loops |
| Blue-green | verify the idle color before switching, then verify again after cutover |
| PaaS | platform 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
- Prepare the release with the Django Deployment Checklist for Production.
- Make rollbacks routine with How to Roll Back a Django Release Safely.
- Debug stale releases with Fix Django Code Changes Not Showing After Deploy.
- Keep watching between deploys with Set Up Uptime Monitoring and Alerts for Django.
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.